zfin/src/views/compare.zig

658 lines
25 KiB
Zig

//! `src/views/compare.zig` — view model for the portfolio comparison UX.
//!
//! Renderer-agnostic display data: no ANSI, no writer, no vaxis. Sits
//! alongside `views/portfolio_sections.zig` and `views/history.zig`
//! in the views layer. CLI and TUI renderers both consume the
//! `CompareView` produced here:
//!
//! - CLI renderer: `src/commands/compare.zig` (ANSI writer)
//! - TUI renderer: `src/tui/history_tab.zig` (vaxis-styled lines)
//!
//! ## Semantics
//!
//! Two snapshots — "then" and "now" — each described as a map of
//! `symbol -> (shares, price)` plus a liquid-value total. "Now" may be
//! either another historical snapshot or the live portfolio; the view
//! doesn't care which.
//!
//! ### Liquid totals
//!
//! Raw delta: `now - then`. This **includes contributions and withdrawals**.
//! The per-symbol section below is the pure investment signal — total-level
//! returns are deliberately not adjusted for flows, because reconstructing
//! contribution history is out of scope.
//!
//! ### Per-symbol price change
//!
//! Only symbols held on **both** dates appear. Added/removed positions are
//! counted but not rendered — that matches the "I don't care about added/
//! removed" constraint and keeps the output scannable.
//!
//! Two per-symbol numbers:
//! - `pct_change` = `price_now / price_then - 1` (price-only, share-count
//! changes between the two dates don't affect it)
//! - `dollar_change` = `min(shares_then, shares_now) * (price_now - price_then)`
//! — the "held throughout" dollar impact. Uses the share floor to
//! isolate continuously-held exposure; shares added between the dates
//! don't contribute (matching the "don't count adds" intent),
//! shares sold don't either.
//!
//! Sorted by `pct_change` descending — biggest winners first.
//!
//! ## Contract
//!
//! No IO, no fetching, no rendering. Callers are responsible for
//! loading snapshots, aggregating lot rows by symbol, and producing
//! the two `HoldingMap`s (see `src/compare.zig`). This module does
//! the math and returns a sorted, styled (via `StyleIntent`) view
//! that either renderer can consume.
const std = @import("std");
const fmt = @import("../format.zig");
const Date = @import("../models/date.zig").Date;
const view_hist = @import("history.zig");
pub const StyleIntent = fmt.StyleIntent;
// ── Data types ───────────────────────────────────────────────
/// A single per-symbol comparison row, pre-computed.
///
/// The `symbol` string is borrowed from the caller's `HoldingMap` — the
/// caller must keep that map (and its backing buffers) alive as long as
/// the view.
pub const SymbolChange = struct {
symbol: []const u8,
price_then: f64,
price_now: f64,
/// `min(shares_then, shares_now)` — the continuously-held floor.
/// Drives `dollar_change`.
shares_held_throughout: f64,
/// Ratio, NOT percentage. `0.05` means +5%. Renderers multiply by
/// 100 at format time via `fmtSignedPercentBuf` or similar.
pct_change: f64,
/// `shares_held_throughout * (price_now - price_then)`. Signed.
dollar_change: f64,
/// `.positive` when pct_change > 0, `.negative` when < 0,
/// `.muted` when exactly zero.
style: StyleIntent,
};
/// The liquid-total row for the totals section. Raw delta (includes flows).
pub const TotalsRow = struct {
then: f64,
now: f64,
/// `now - then`.
delta: f64,
/// Ratio. `0.05` means +5%. Zero when `then` is zero (avoids NaN).
pct: f64,
/// `.positive`/`.negative`/`.muted` by `delta` sign.
style: StyleIntent,
};
/// Complete compare view. `symbols` is caller-owned; call `deinit()`.
pub const CompareView = struct {
then_date: Date,
now_date: Date,
/// `now_date.days - then_date.days`. Can be zero (same-day compare
/// is a no-op but won't error) or negative if the caller didn't
/// normalize order, but the CLI always swaps so it's ≥ 0 in
/// practice.
days_between: i32,
/// True when the "now" side is the live portfolio (not another
/// snapshot). Renderers can use this to distinguish "compared to
/// today" from "compared to another snapshot date".
now_is_live: bool,
liquid: TotalsRow,
/// Sorted by `pct_change` descending. Owned by the view.
symbols: []SymbolChange,
/// Count of held-throughout symbols — always `== symbols.len`;
/// surfaced here for convenience in rendering the "(N held
/// throughout)" subtitle.
held_count: usize,
/// Symbols present in "now" but not "then" — position opened
/// between the two dates. Never rendered as rows; shown as a count.
added_count: usize,
/// Symbols present in "then" but not "now" — position closed
/// between the two dates. Never rendered as rows; shown as a count.
removed_count: usize,
pub fn deinit(self: *CompareView, allocator: std.mem.Allocator) void {
allocator.free(self.symbols);
}
};
/// One entry in a holdings snapshot — total shares held of `symbol` and
/// the per-share price at that moment. Caller-populated; the view model
/// doesn't know or care where the numbers came from.
pub const Holding = struct {
shares: f64,
price: f64,
};
/// Symbol → Holding. String keys are caller-owned; keep them alive as
/// long as the resulting `CompareView`.
pub const HoldingMap = std.StringHashMap(Holding);
// ── Pure builders ────────────────────────────────────────────
/// Compute a single per-symbol change from the raw inputs.
///
/// The pct-change denominator is `price_then`. If `price_then` is zero
/// (shouldn't happen for stocks but guards against bad data), the
/// pct_change is reported as 0 rather than a NaN/Inf leaking into the
/// sort comparator.
pub fn buildSymbolChange(
symbol: []const u8,
shares_then: f64,
price_then: f64,
shares_now: f64,
price_now: f64,
) SymbolChange {
const held = @min(shares_then, shares_now);
const pct = if (price_then != 0) (price_now / price_then - 1.0) else 0.0;
const dollar = held * (price_now - price_then);
const style: StyleIntent = if (pct > 0) .positive else if (pct < 0) .negative else .muted;
return .{
.symbol = symbol,
.price_then = price_then,
.price_now = price_now,
.shares_held_throughout = held,
.pct_change = pct,
.dollar_change = dollar,
.style = style,
};
}
/// Compute the liquid totals row. Safe when `then == 0` (pct → 0 rather
/// than NaN).
pub fn buildTotalsRow(then: f64, now: f64) TotalsRow {
const delta = now - then;
const pct = if (then != 0) delta / then else 0.0;
const style: StyleIntent = if (delta > 0) .positive else if (delta < 0) .negative else .muted;
return .{
.then = then,
.now = now,
.delta = delta,
.pct = pct,
.style = style,
};
}
/// Primary view builder. Intersects `then_map` with `now_map`, computes
/// per-symbol changes for held-throughout symbols, counts added/removed,
/// and sorts descending by `pct_change`.
///
/// Symbol strings in the returned view borrow from `then_map`'s keys
/// (since we iterate `then_map` to build the intersection) — the caller
/// must keep `then_map` alive at least as long as the view. Alternatively,
/// if the view needs to outlive both maps, the caller can dupe the
/// strings before passing them in.
pub fn buildCompareView(
allocator: std.mem.Allocator,
then_date: Date,
now_date: Date,
now_is_live: bool,
liquid_then: f64,
liquid_now: f64,
then_map: *const HoldingMap,
now_map: *const HoldingMap,
) !CompareView {
var changes: std.ArrayList(SymbolChange) = .empty;
errdefer changes.deinit(allocator);
var added: usize = 0;
var removed: usize = 0;
// Count added: in now, not in then.
var now_it = now_map.iterator();
while (now_it.next()) |e| {
if (!then_map.contains(e.key_ptr.*)) added += 1;
}
// Walk "then" to build the intersection and count removed.
var then_it = then_map.iterator();
while (then_it.next()) |e| {
const sym = e.key_ptr.*;
const then_h = e.value_ptr.*;
if (now_map.get(sym)) |now_h| {
try changes.append(allocator, buildSymbolChange(
sym,
then_h.shares,
then_h.price,
now_h.shares,
now_h.price,
));
} else {
removed += 1;
}
}
// Sort by pct_change descending. Stable is fine; stability isn't
// semantically relevant here but is cheaper in the not-all-unique case.
std.mem.sort(SymbolChange, changes.items, {}, struct {
fn lt(_: void, a: SymbolChange, b: SymbolChange) bool {
return a.pct_change > b.pct_change;
}
}.lt);
const items = try changes.toOwnedSlice(allocator);
return .{
.then_date = then_date,
.now_date = now_date,
.days_between = now_date.days - then_date.days,
.now_is_live = now_is_live,
.liquid = buildTotalsRow(liquid_then, liquid_now),
.symbols = items,
.held_count = items.len,
.added_count = added,
.removed_count = removed,
};
}
// ── Tests ────────────────────────────────────────────────────
const testing = std.testing;
test "buildSymbolChange: positive price move, shares stable" {
const c = buildSymbolChange("AAPL", 100, 150.0, 100, 165.0);
try testing.expectEqualStrings("AAPL", c.symbol);
try testing.expectApproxEqAbs(@as(f64, 0.10), c.pct_change, 1e-9);
try testing.expectApproxEqAbs(@as(f64, 1500.0), c.dollar_change, 1e-9);
try testing.expectEqual(@as(f64, 100), c.shares_held_throughout);
try testing.expectEqual(StyleIntent.positive, c.style);
}
test "buildSymbolChange: negative move" {
const c = buildSymbolChange("NFLX", 10, 500.0, 10, 400.0);
try testing.expectApproxEqAbs(@as(f64, -0.20), c.pct_change, 1e-9);
try testing.expectApproxEqAbs(@as(f64, -1000.0), c.dollar_change, 1e-9);
try testing.expectEqual(StyleIntent.negative, c.style);
}
test "buildSymbolChange: zero price move → muted style, zero dollar" {
const c = buildSymbolChange("VTI", 50, 240.0, 50, 240.0);
try testing.expectApproxEqAbs(@as(f64, 0.0), c.pct_change, 1e-9);
try testing.expectApproxEqAbs(@as(f64, 0.0), c.dollar_change, 1e-9);
try testing.expectEqual(StyleIntent.muted, c.style);
}
test "buildSymbolChange: shares_held is min (added shares between dates)" {
// Started with 100, added 50, now at 150. Held-throughout floor = 100.
const c = buildSymbolChange("MSFT", 100, 400.0, 150, 420.0);
try testing.expectEqual(@as(f64, 100), c.shares_held_throughout);
// dollar = 100 * (420-400) = 2000, not 150 * 20 = 3000
try testing.expectApproxEqAbs(@as(f64, 2000.0), c.dollar_change, 1e-9);
}
test "buildSymbolChange: shares_held is min (sold shares between dates)" {
// Started with 200, sold down to 50. Held-throughout floor = 50.
const c = buildSymbolChange("GOOG", 200, 160.0, 50, 180.0);
try testing.expectEqual(@as(f64, 50), c.shares_held_throughout);
// dollar = 50 * (180-160) = 1000
try testing.expectApproxEqAbs(@as(f64, 1000.0), c.dollar_change, 1e-9);
}
test "buildSymbolChange: zero price_then doesn't NaN" {
const c = buildSymbolChange("BAD", 10, 0.0, 10, 50.0);
try testing.expectEqual(@as(f64, 0.0), c.pct_change);
try testing.expectEqual(StyleIntent.muted, c.style);
// dollar_change is still 10 * 50 = 500 — that's the true held-throughout
// price delta even though % is undefined.
try testing.expectApproxEqAbs(@as(f64, 500.0), c.dollar_change, 1e-9);
}
test "buildTotalsRow: positive delta" {
const t = buildTotalsRow(1_000_000.0, 1_050_000.0);
try testing.expectApproxEqAbs(@as(f64, 50_000.0), t.delta, 1e-6);
try testing.expectApproxEqAbs(@as(f64, 0.05), t.pct, 1e-9);
try testing.expectEqual(StyleIntent.positive, t.style);
}
test "buildTotalsRow: negative delta" {
const t = buildTotalsRow(1_000_000.0, 950_000.0);
try testing.expectApproxEqAbs(@as(f64, -50_000.0), t.delta, 1e-6);
try testing.expectApproxEqAbs(@as(f64, -0.05), t.pct, 1e-9);
try testing.expectEqual(StyleIntent.negative, t.style);
}
test "buildTotalsRow: zero delta" {
const t = buildTotalsRow(100.0, 100.0);
try testing.expectEqual(@as(f64, 0.0), t.delta);
try testing.expectEqual(StyleIntent.muted, t.style);
}
test "buildTotalsRow: zero then doesn't NaN" {
const t = buildTotalsRow(0.0, 100.0);
try testing.expectEqual(@as(f64, 0.0), t.pct);
try testing.expectEqual(StyleIntent.positive, t.style); // delta > 0 so positive
}
test "buildCompareView: intersection with added and removed" {
var then_map: HoldingMap = .init(testing.allocator);
defer then_map.deinit();
var now_map: HoldingMap = .init(testing.allocator);
defer now_map.deinit();
// Held in both (will appear)
try then_map.put("AAPL", .{ .shares = 100, .price = 150.0 });
try now_map.put("AAPL", .{ .shares = 100, .price = 165.0 });
try then_map.put("MSFT", .{ .shares = 50, .price = 400.0 });
try now_map.put("MSFT", .{ .shares = 50, .price = 395.0 });
// Removed (in then, not in now)
try then_map.put("NFLX", .{ .shares = 10, .price = 500.0 });
// Added (in now, not in then)
try now_map.put("TSLA", .{ .shares = 20, .price = 250.0 });
try now_map.put("NVDA", .{ .shares = 15, .price = 140.0 });
var view = try buildCompareView(
testing.allocator,
Date.fromYmd(2026, 4, 20),
Date.fromYmd(2026, 4, 30),
true, // live
1_000_000.0,
1_050_000.0,
&then_map,
&now_map,
);
defer view.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 2), view.held_count);
try testing.expectEqual(@as(usize, 2), view.added_count);
try testing.expectEqual(@as(usize, 1), view.removed_count);
try testing.expectEqual(@as(i32, 10), view.days_between);
try testing.expectEqual(true, view.now_is_live);
// Sort order: AAPL (+10%) before MSFT (-1.25%)
try testing.expectEqualStrings("AAPL", view.symbols[0].symbol);
try testing.expectEqualStrings("MSFT", view.symbols[1].symbol);
try testing.expect(view.symbols[0].pct_change > view.symbols[1].pct_change);
// Totals row
try testing.expectApproxEqAbs(@as(f64, 0.05), view.liquid.pct, 1e-9);
try testing.expectEqual(StyleIntent.positive, view.liquid.style);
}
test "buildCompareView: empty intersection" {
var then_map: HoldingMap = .init(testing.allocator);
defer then_map.deinit();
var now_map: HoldingMap = .init(testing.allocator);
defer now_map.deinit();
try then_map.put("OLD", .{ .shares = 10, .price = 100.0 });
try now_map.put("NEW", .{ .shares = 5, .price = 200.0 });
var view = try buildCompareView(
testing.allocator,
Date.fromYmd(2026, 1, 1),
Date.fromYmd(2026, 2, 1),
false,
1000.0,
2000.0,
&then_map,
&now_map,
);
defer view.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 0), view.held_count);
try testing.expectEqual(@as(usize, 1), view.added_count);
try testing.expectEqual(@as(usize, 1), view.removed_count);
try testing.expectEqual(false, view.now_is_live);
}
test "buildCompareView: both empty" {
var then_map: HoldingMap = .init(testing.allocator);
defer then_map.deinit();
var now_map: HoldingMap = .init(testing.allocator);
defer now_map.deinit();
var view = try buildCompareView(
testing.allocator,
Date.fromYmd(2026, 1, 1),
Date.fromYmd(2026, 1, 1),
true,
0.0,
0.0,
&then_map,
&now_map,
);
defer view.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 0), view.held_count);
try testing.expectEqual(@as(usize, 0), view.added_count);
try testing.expectEqual(@as(usize, 0), view.removed_count);
try testing.expectEqual(@as(i32, 0), view.days_between);
try testing.expectEqual(StyleIntent.muted, view.liquid.style);
}
test "buildCompareView: sort strictly descending across many symbols" {
var then_map: HoldingMap = .init(testing.allocator);
defer then_map.deinit();
var now_map: HoldingMap = .init(testing.allocator);
defer now_map.deinit();
// Seed 5 held-throughout symbols with distinct returns
try then_map.put("A", .{ .shares = 10, .price = 100.0 });
try now_map.put("A", .{ .shares = 10, .price = 110.0 }); // +10%
try then_map.put("B", .{ .shares = 10, .price = 100.0 });
try now_map.put("B", .{ .shares = 10, .price = 80.0 }); // -20%
try then_map.put("C", .{ .shares = 10, .price = 100.0 });
try now_map.put("C", .{ .shares = 10, .price = 130.0 }); // +30%
try then_map.put("D", .{ .shares = 10, .price = 100.0 });
try now_map.put("D", .{ .shares = 10, .price = 95.0 }); // -5%
try then_map.put("E", .{ .shares = 10, .price = 100.0 });
try now_map.put("E", .{ .shares = 10, .price = 105.0 }); // +5%
var view = try buildCompareView(
testing.allocator,
Date.fromYmd(2026, 1, 1),
Date.fromYmd(2026, 2, 1),
false,
5000.0,
5200.0,
&then_map,
&now_map,
);
defer view.deinit(testing.allocator);
try testing.expectEqual(@as(usize, 5), view.held_count);
// Expected order by pct desc: C (+30), A (+10), E (+5), D (-5), B (-20)
try testing.expectEqualStrings("C", view.symbols[0].symbol);
try testing.expectEqualStrings("A", view.symbols[1].symbol);
try testing.expectEqualStrings("E", view.symbols[2].symbol);
try testing.expectEqualStrings("D", view.symbols[3].symbol);
try testing.expectEqualStrings("B", view.symbols[4].symbol);
}
// ── Layout constants + row-cell builders ─────────────────────
//
// Shared between the CLI and TUI renderers. Before this section
// existed, both renderers duplicated the column widths, format
// strings, and money/percent formatting — which is exactly the drift
// hazard `views/history.zig` was built to prevent. Every width or
// label change now lives here.
/// Symbol column width — fits "BRK-B" + a note-derived CUSIP label
/// like "TGT2035" with slack.
pub const symbol_w: usize = 8;
/// Per-price column width. Fits "$999,999.99".
pub const price_w: usize = 10;
/// Percent column width. Fits "+999.99%".
pub const pct_w: usize = 8;
/// Signed-dollar column width. Fits "+$99,999,999.99" with slack.
pub const dollar_w: usize = 14;
/// Transition glyph between the `then` and `now` cells.
pub const arrow: []const u8 = "";
// Comptime-built format specifiers so callers don't hardcode widths
// that might drift from the constants above. `cp` stringifies the
// width into a real Zig format spec; both CLI and TUI renderers
// compose these into their own line templates.
const cp = std.fmt.comptimePrint;
pub const symbol_fmt = cp("{{s:<{d}}}", .{symbol_w});
pub const price_left_fmt = cp("{{s:<{d}}}", .{price_w});
pub const price_right_fmt = cp("{{s:>{d}}}", .{price_w});
pub const pct_fmt = cp("{{s:>{d}}}", .{pct_w});
pub const dollar_fmt = cp("{{s:>{d}}}", .{dollar_w});
/// Single-color per-symbol row template. Ordered fields:
/// { symbol, price_then, arrow, price_now, pct, dollar }
/// Used by the TUI renderer (one style per line); the CLI composes
/// the row from smaller pieces to get per-segment coloring.
pub const symbol_row_fmt = symbol_fmt ++ " " ++ price_right_fmt ++
"{s}" ++ price_left_fmt ++ " " ++ pct_fmt ++ " " ++ dollar_fmt;
/// Pre-formatted cells for a single per-symbol row. Strings borrow
/// from the caller-owned buffers passed into `buildSymbolRowCells`.
///
/// `style` is a semantic intent; renderers map to their own style
/// system (CLI: ANSI via `cli.setStyleIntent`, TUI: vaxis via
/// `theme.styleFor`).
pub const SymbolRowCells = struct {
symbol: []const u8,
price_then: []const u8,
price_now: []const u8,
pct: []const u8,
dollar: []const u8,
style: StyleIntent,
};
/// Build a single SymbolChange into display-ready cells. The four
/// caller-owned buffers back the returned strings and must outlive
/// the result.
pub fn buildSymbolRowCells(
s: SymbolChange,
price_then_buf: *[24]u8,
price_now_buf: *[24]u8,
pct_buf: *[16]u8,
dollar_buf: *[32]u8,
) SymbolRowCells {
return .{
.symbol = s.symbol,
.price_then = fmt.fmtMoneyAbs(price_then_buf, s.price_then),
.price_now = fmt.fmtMoneyAbs(price_now_buf, s.price_now),
.pct = view_hist.fmtSignedPercentBuf(pct_buf, s.pct_change),
.dollar = view_hist.fmtSignedMoneyBuf(dollar_buf, s.dollar_change),
.style = s.style,
};
}
/// Pre-formatted cells for the liquid totals line (then → now, delta,
/// pct). Strings borrow from caller-owned buffers.
pub const TotalsCells = struct {
then: []const u8,
now: []const u8,
delta: []const u8,
pct: []const u8,
/// Style for the delta/pct portion; the then/now portion is
/// typically rendered in a muted/secondary style so the delta
/// stands out. CLI honors this split; the TUI applies `style` to
/// the whole line for simplicity.
style: StyleIntent,
};
pub fn buildTotalsCells(
t: TotalsRow,
then_buf: *[24]u8,
now_buf: *[24]u8,
delta_buf: *[32]u8,
pct_buf: *[16]u8,
) TotalsCells {
return .{
.then = fmt.fmtMoneyAbs(then_buf, t.then),
.now = fmt.fmtMoneyAbs(now_buf, t.now),
.delta = view_hist.fmtSignedMoneyBuf(delta_buf, t.delta),
.pct = view_hist.fmtSignedPercentBuf(pct_buf, t.pct),
.style = t.style,
};
}
/// Format the "now" side label for the header. Snapshot-now shows
/// the date; live-now shows the literal "today". `buf` backs the
/// date case; caller must keep it alive.
pub fn nowLabel(cv: CompareView, buf: *[10]u8) []const u8 {
if (cv.now_is_live) return "today";
return cv.now_date.format(buf);
}
/// English pluralization for the "(N day[s])" header suffix.
pub fn dayPlural(n: i32) []const u8 {
return if (n == 1) "" else "s";
}
test "buildSymbolRowCells: wires through the right formatters" {
var p_then: [24]u8 = undefined;
var p_now: [24]u8 = undefined;
var p_pct: [16]u8 = undefined;
var p_dollar: [32]u8 = undefined;
const s = SymbolChange{
.symbol = "FOO",
.price_then = 100.00,
.price_now = 110.00,
.shares_held_throughout = 10,
.pct_change = 0.10,
.dollar_change = 100.0,
.style = .positive,
};
const cells = buildSymbolRowCells(s, &p_then, &p_now, &p_pct, &p_dollar);
try testing.expectEqualStrings("FOO", cells.symbol);
try testing.expectEqualStrings("$100.00", cells.price_then);
try testing.expectEqualStrings("$110.00", cells.price_now);
try testing.expectEqualStrings("+10.00%", cells.pct);
try testing.expectEqualStrings("+$100.00", cells.dollar);
try testing.expectEqual(StyleIntent.positive, cells.style);
}
test "buildTotalsCells: wires through the right formatters" {
var b_then: [24]u8 = undefined;
var b_now: [24]u8 = undefined;
var b_delta: [32]u8 = undefined;
var b_pct: [16]u8 = undefined;
const t = buildTotalsRow(10_000, 10_500);
const cells = buildTotalsCells(t, &b_then, &b_now, &b_delta, &b_pct);
try testing.expectEqualStrings("$10,000.00", cells.then);
try testing.expectEqualStrings("$10,500.00", cells.now);
try testing.expectEqualStrings("+$500.00", cells.delta);
try testing.expectEqualStrings("+5.00%", cells.pct);
try testing.expectEqual(StyleIntent.positive, cells.style);
}
test "nowLabel: live shows 'today', snapshot shows date" {
const cv_live = CompareView{
.then_date = Date.fromYmd(2024, 1, 15),
.now_date = Date.fromYmd(2024, 3, 15),
.days_between = 60,
.now_is_live = true,
.liquid = buildTotalsRow(100, 100),
.symbols = &.{},
.held_count = 0,
.added_count = 0,
.removed_count = 0,
};
var buf: [10]u8 = undefined;
try testing.expectEqualStrings("today", nowLabel(cv_live, &buf));
const cv_snap = CompareView{
.then_date = Date.fromYmd(2024, 1, 15),
.now_date = Date.fromYmd(2024, 3, 15),
.days_between = 60,
.now_is_live = false,
.liquid = buildTotalsRow(100, 100),
.symbols = &.{},
.held_count = 0,
.added_count = 0,
.removed_count = 0,
};
try testing.expectEqualStrings("2024-03-15", nowLabel(cv_snap, &buf));
}
test "dayPlural: 1 day singular, everything else plural" {
try testing.expectEqualStrings("", dayPlural(1));
try testing.expectEqualStrings("s", dayPlural(0));
try testing.expectEqualStrings("s", dayPlural(2));
try testing.expectEqualStrings("s", dayPlural(60));
}