//! `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, }; /// Optional attribution breakdown of the liquid delta into /// contributions (money in) vs investment gains (market movement). /// /// Populated in both single-date ("vs current") and two-date modes /// when the portfolio is git-tracked and both endpoints resolve to /// commits via `git.resolveCommitRange`. Silently null when the git /// lookup fails (missing repo, untracked file, no commits in range); /// the compare view falls back to just the totals + per-symbol table. /// /// Math: `delta = contributions + gains`, so `gains = delta - contributions`. /// Signs are preserved: negative contributions (net withdrawal) and /// negative gains (market loss) both appear. pub const Attribution = struct { /// Sum of new-money contributions plus DRIP reinvestments (what /// `zfin contributions` reports as "money in"). contributions: f64, /// `TotalsRow.delta - contributions`. The residual — what the /// market actually did. gains: f64, }; /// 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, /// Optional contributions-vs-gains breakdown of `liquid.delta`. /// Populated by the CLI from `computeAttribution` when a git repo /// is available; always null in unit-tested / TUI flows. attribution: ?Attribution = null, 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); } /// Re-export of `format.dayPlural` so callers keep a single import. /// The canonical implementation lives in `src/format.zig`. pub const dayPlural = fmt.dayPlural; 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)); }