diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e078871..58b8316 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: - id: test name: Run zig build test entry: zig - args: ["build", "coverage", "-Dcoverage-threshold=62"] + args: ["build", "coverage", "-Dcoverage-threshold=65"] language: system types: [file] pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index d29004a..ef9bed5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,6 +57,27 @@ current time. the top of the unit of work (`runCli` for CLI, `App.init` for TUI) and threaded through. Render output stays deterministic within a frame even if the clock ticks over mid-render. + + - **Use the name `today` only when the parameter genuinely + means "the current calendar day."** That's the App-level + `app.today` field and the per-command `today` arg threaded + from `runCli`. The caller can't reasonably pass anything + other than the actual current day to these. + - **Use `as_of: Date` when the parameter is the reference + date for a computation, and the caller might legitimately + pass any value.** Examples: rolling-windows blocks (the + `--as-of` flag back-dates the view), CAGR / annualization + helpers, snapshot-resolution helpers. The function should + work correctly whether the caller passes today's date, + last week's date, or 2014. Calling such a parameter + `today` invites bugs because future maintainers will + assume the function uses it as "now" rather than "the + reference date you asked about." + - When in doubt: if the function's behavior would be + nonsensical with a date that isn't today, use `today`. + Otherwise use `as_of`. Most analytics functions are + `as_of`. + - **`now_s: i64` (or similar `before_s`/`after_s` pairs) is passed as a value** for sub-second-precision metadata fields like snapshot `captured_at`, rollup `#!created=`, audit cadence @@ -186,7 +207,8 @@ zig build # build the zfin binary (output: zig-out/bin/zfin) zig build test # run all tests (single binary, discovers all tests via refAllDecls + the import graph) zig build run -- # build and run CLI zig build docs # generate library documentation -zig build coverage -Dcoverage-threshold=60 # run tests with kcov coverage (Linux only) +zig build coverage # run tests with kcov coverage (Linux only). See "Coverage" section. +zig build coverage -Dcoverage-threshold=65 # fail build if coverage < N% (pre-commit uses 65) ``` **Tooling** (managed via `.mise.toml`): @@ -282,64 +304,115 @@ All tests are inline (in `test` blocks within source files). There is a single t Tests use `std.testing.allocator` (which detects leaks) and are structured as unit tests that verify individual functions. Network-dependent code is not tested (no mocking infrastructure). -### ⚠️ Test discovery — READ THIS BEFORE ADDING A NEW .zig FILE WITH TESTS ⚠️ +### Test discovery -**This gets fucked up every single session. Read it. Do what it says.** +`zig build test` runs every `test` block in every file reachable from +`src/main.zig` via the import graph, courtesy of +`std.testing.refAllDecls(@This())` in main.zig's bottom `test {}` block. +"Reachable" means: somewhere in the chain there's a +`const foo = @import("foo.zig");` (or any other binding to the imported +file struct, including `pub const`, struct field, etc). -`zig build test` runs tests from `test` blocks in files that are part of the -test binary's compilation unit AND get sema-pulled by the import graph from -`src/main.zig`. With the bare `std.testing.refAllDecls(@This())` we use, a -file's tests are collected as long as the file is imported (directly or -transitively) from main.zig. +**The verification workflow is dead simple:** -**The failure mode:** you add `src/models/foo.zig` with 20 tests. You wire -it into `src/service.zig` only as a *type extraction*, e.g. -`const Bar = @import("models/foo.zig").Bar;` (assigning the type, not the -file struct). The file **compiles** because `Bar` is referenced, but the -file struct itself was never sema-touched as a struct, so its `test` -blocks are not collected. - -**The fix:** ensure at least one importer assigns the file struct to a -`const`, like `const foo = @import("models/foo.zig");`. Even if you only -use a type from it, the `const foo` form pulls in the file's `test` blocks. - -**How to verify a new file's tests are discovered:** - -1. Before relying on the test count, add a canary that MUST fail: - ```zig - test "CANARY_DISCOVERY_CHECK_REMOVE_ME" { - try std.testing.expect(false); - } - ``` -2. Run `zig build test --summary all 2>&1 | grep -E "tests passed|error:"`. -3. If the canary test appears in failures → discovery works, remove canary. -4. If the canary does NOT appear and total count is unchanged → ensure - the file is imported via a `const x = @import(...)` form somewhere - reachable from main.zig. - -**Fallback fix:** if you can't fix the import shape, add an explicit -import in the `test` block at the bottom of `src/main.zig`: - -```zig -test { - std.testing.refAllDecls(@This()); - _ = @import("models/foo.zig"); // ← orphaned file -} +```bash +zig build test --summary all 2>&1 | grep "tests passed" ``` -Adding it inside the `test` block (not at file scope as a `comptime` block) -keeps the non-test build unaffected while guaranteeing the test binary -sema-reaches the file and collects its test blocks. +Run it before and after a change. If the count moved the way you expected, +you're done. If it didn't, fix it. There is no further analysis required — +no manual graph walking, no canary tests, no dependency archaeology. -**Rule of thumb:** after adding ANY new `.zig` file under `src/` that contains -`test` blocks, run `zig build test --summary all 2>&1 | grep "tests passed"` -BEFORE and AFTER the change. If the delta doesn't match -`rg -c "^test " path/to/new_file.zig`, add the explicit import to main.zig's -test block. +**The one gotcha:** if you import a file purely as a type extraction — +`const T = @import("foo.zig").T;` — the test blocks in `foo.zig` are NOT +collected. The fix is to bind the file struct itself somewhere: +`const foo = @import("foo.zig");`. You'll know this happened because the +test count won't go up after adding tests to a new file. The `_ = @import(...)` +escape hatch in main.zig's test block is also fine if reshaping imports is +inconvenient — `refAllDecls` will sema-touch it. -**Do NOT, under any circumstance, try to "fix" this by clearing the cache.** -The cache is not the problem. The import graph is the problem. Re-read -the prohibitions at the top of this file. +**Do NOT clear the cache "to be sure."** Cache is content-addressed; it +isn't the problem. See the prohibitions at the top of this file. + +### Coverage + +Test coverage is measured by `zig build coverage`, which runs the +test binary under [kcov](https://simonkagstrom.github.io/kcov/) and +emits an HTML report under `coverage/` plus a one-line summary on +stdout: + +``` +Total test coverage: 65.15% (15399/23638) +``` + +**The pre-commit hook enforces a coverage floor.** The current +floor is **65%** (set in `.pre-commit-config.yaml`). The hook runs +`zig build coverage -Dcoverage-threshold=65` and fails the commit +if coverage drops below that threshold. Bumping the floor over time +is encouraged — every time we push the actual coverage materially +higher, raise the floor in the pre-commit config in the same commit +so the gain is locked in. + +**Coverage expectations for new work:** + +1. **For most features, coverage should go UP, or you should be + able to explain why not.** New analytics modules, parsers, + loaders, formatters, and pure-domain transforms are easy to + cover and should be — they're the load-bearing logic. New + tests on existing files also nudge the percentage up by + exercising more lines of the same code. + +2. **It's fine for some additions to push coverage flat or + slightly down.** Examples: + - **New TUI rendering paths.** TUI code that needs an + interactive vaxis context (event handlers, draw callbacks, + mouse handling) is hard to test without a vaxis-aware test + harness, which we don't have. Adding a new tab will add + uncovered lines unless you can extract a pure render + function and test it in isolation. + - **New provider HTTP code.** We don't mock providers; live + network calls aren't run in tests. Provider request/response + parsers ARE testable (and SHOULD be tested) — extract them + from the HTTP-bound code so they can be exercised with + fixture bytes. + - **CLI command dispatch glue in `src/main.zig`.** The command + match chain itself doesn't need tests; the command's + `run()` function and helpers should. + +3. **If coverage drops, document why in the commit message.** A + single sentence — "Adds TUI tab; pure render fn covered; + event handlers and mouse handlers uncovered, no test + harness" — is enough. Future-you will thank present-you. + +**How to investigate uncovered lines:** + +```bash +zig build coverage +# Open coverage/index.html in a browser, or: +ls coverage/ # per-file HTML reports +``` + +Each file's report shows red lines (uncovered) and green lines +(covered). For a quick numeric breakdown by file, the kcov JSON +output under `coverage/kcov-merged/coverage.json` is greppable. + +**Common reasons coverage looks lower than expected:** + +- A new `.zig` file's tests aren't being discovered. Check the + Test discovery section above. The tests pass-or-fail report + will say "0 tests" for that module if it's orphaned. +- A function has many branches but the tests only exercise the + happy path. Add error-path tests with `expectError`. +- A switch over many enum variants only tests one. Loop the test + over all variants. +- Code is dead. Either start using it or delete it. + +**Don't game the metric.** If you find yourself adding tests that +don't actually verify behavior just to pump the percentage — +`try expect(true)` calls, tests that only construct types and +check field defaults, etc. — stop. The gate exists to catch real +regressions in test discipline; gaming it produces tests that +will fail to catch real bugs later. ### Adding a new CLI command diff --git a/src/analytics/milestones.zig b/src/analytics/milestones.zig new file mode 100644 index 0000000..bb19f79 --- /dev/null +++ b/src/analytics/milestones.zig @@ -0,0 +1,515 @@ +//! Pure crossing-detection for the `zfin milestones` command. +//! +//! Given a `(date, value)` series, find the dates the value +//! first reaches each of a configured set of thresholds. Two +//! threshold modes: +//! +//! - **Absolute** — fixed multiples of a step (e.g., +//! `$1M, $2M, $3M, ...`). +//! - **Relative** — geometric multiples of the series' +//! starting value (e.g., `1x, 2x, 4x, 8x, ...`). +//! +//! No I/O, no allocation outside the result slice. The caller +//! supplies the merged `(date, value)` series. Inflation +//! adjustment is applied at the call site by deflating the +//! series before invoking `detectCrossings` — this keeps the +//! detector ignorant of inflation semantics. + +const std = @import("std"); +const Date = @import("../models/date.zig").Date; + +// ── Step expression parser ─────────────────────────────────── + +/// User-specified step. Drives whether thresholds are absolute +/// dollar multiples or geometric multipliers of the starting +/// value. +pub const Step = union(enum) { + /// `--step 1M` → `.{ .absolute = 1_000_000 }`. + absolute: f64, + /// `--step 2x` → `.{ .relative = 2.0 }`. + relative: f64, +}; + +pub const ParseStepError = error{ + Empty, + InvalidNumber, + NonFiniteValue, + NonPositiveAbsolute, + NonProgressiveRelative, + UnknownSuffix, +}; + +/// Parse a step expression. Accepts: +/// - Bare numbers: `1000000` +/// - Absolute with `K`/`k`/`M`/`m` suffix: `1M`, `500k`, `1.5M` +/// - Relative with `x`/`X` suffix: `2x`, `1.5x` +/// +/// Rejects: +/// - Empty input +/// - Non-finite values (NaN, Inf) +/// - Zero or negative absolute values +/// - Relative values ≤ 1.0 (no progression) +/// - `%` suffix (intentionally unsupported — see spec) +/// - Any other suffix character +/// +/// Whitespace is NOT trimmed; callers should pre-trim. +pub fn parseStep(input: []const u8) ParseStepError!Step { + if (input.len == 0) return error.Empty; + + const last = input[input.len - 1]; + + // Relative form. + if (last == 'x' or last == 'X') { + if (input.len == 1) return error.InvalidNumber; + const num_str = input[0 .. input.len - 1]; + const v = std.fmt.parseFloat(f64, num_str) catch return error.InvalidNumber; + if (!std.math.isFinite(v)) return error.NonFiniteValue; + if (v <= 1.0) return error.NonProgressiveRelative; + return .{ .relative = v }; + } + + // Absolute with K/M suffix. + var multiplier: f64 = 1.0; + var num_str: []const u8 = input; + if (last == 'K' or last == 'k') { + if (input.len == 1) return error.InvalidNumber; + multiplier = 1_000.0; + num_str = input[0 .. input.len - 1]; + } else if (last == 'M' or last == 'm') { + if (input.len == 1) return error.InvalidNumber; + multiplier = 1_000_000.0; + num_str = input[0 .. input.len - 1]; + } else if (!std.ascii.isDigit(last) and last != '.') { + // A non-digit, non-period trailing character that isn't + // x/X/K/k/M/m may still be a legitimate float literal + // (`inf`, `nan`, `1e10`). Defer to `parseFloat` and only + // reject as unknown suffix if parsing fails AND we're + // confident the input doesn't look like a float. + const v_try = std.fmt.parseFloat(f64, input) catch { + return error.UnknownSuffix; + }; + if (!std.math.isFinite(v_try)) return error.NonFiniteValue; + if (v_try <= 0.0) return error.NonPositiveAbsolute; + return .{ .absolute = v_try }; + } + + const v = std.fmt.parseFloat(f64, num_str) catch return error.InvalidNumber; + if (!std.math.isFinite(v)) return error.NonFiniteValue; + const absolute = v * multiplier; + if (absolute <= 0.0) return error.NonPositiveAbsolute; + return .{ .absolute = absolute }; +} + +// ── Series + crossing types ────────────────────────────────── + +/// Input series row. Caller supplies these in date-ascending +/// order (the detector trusts that ordering and does not sort). +pub const Point = struct { + date: Date, + value: f64, +}; + +/// One detected crossing. `days_since_prev` is null for the +/// first crossing in the result; `days_since_first` is 0 for +/// the first crossing. +pub const Crossing = struct { + /// 1-indexed crossing number. Useful for relative mode where + /// the threshold is `start * factor^(index-1)`. + index: u32, + /// The threshold reached. + threshold: f64, + /// Date of the first observed value at or above `threshold`. + /// Per spec, this is "first observed at" rather than + /// "actually crossed on" — the resolution is bounded by the + /// source series' cadence (typically weekly). + date: Date, + /// Days from the previous crossing in this result. Null for + /// the first crossing. + days_since_prev: ?i32, + /// Days from the first crossing in this result. + days_since_first: i32, + /// True iff this crossing is the synthetic "starting point" + /// row — value at the start of the series, not a true + /// crossing. Renderers typically annotate this with a + /// footnote. + is_start: bool, +}; + +// ── Crossing detection ─────────────────────────────────────── + +/// Detect threshold crossings in `series`. Result is allocated +/// via `allocator`; caller owns the slice and frees with +/// `allocator.free`. +/// +/// Returns an empty slice if `series` is empty. +/// +/// Algorithm: +/// - For absolute mode, thresholds are `step, 2*step, 3*step, ...` +/// up to the series' maximum value. +/// - For relative mode, thresholds are `start, start*f, start*f^2, ...` +/// up to the series' maximum value. The starting value is +/// emitted as a synthetic crossing (`is_start = true`). +/// - For each threshold T, scan forward from the current +/// position to find the first index i where +/// `series[i].value >= T`. Record `series[i].date`. +/// - When multiple thresholds fall between two adjacent points +/// (a single big jump), all those thresholds are recorded +/// with the same crossing date. +pub fn detectCrossings( + allocator: std.mem.Allocator, + series: []const Point, + step: Step, +) ![]Crossing { + if (series.len == 0) return &.{}; + + const start_value = series[0].value; + const start_date = series[0].date; + var max_value: f64 = start_value; + for (series) |p| { + if (p.value > max_value) max_value = p.value; + } + + var out: std.ArrayList(Crossing) = .empty; + errdefer out.deinit(allocator); + + var index: u32 = 1; + var prev_date: ?Date = null; + + switch (step) { + .absolute => |s| { + // Absolute thresholds: s, 2s, 3s, ... + // Skip thresholds entirely below the starting value + // (no crossing observable). + var k: u32 = 1; + // Position the scan cursor; thresholds are detected + // by walking forward through the series for each one. + while (true) : (k += 1) { + const T = s * @as(f64, @floatFromInt(k)); + if (T > max_value) break; + + // Find first index where value >= T. + const cross = findFirstAtOrAbove(series, T) orelse continue; + + // Skip thresholds that the starting value is + // ALREADY at or above — those aren't observed + // crossings. + if (T <= start_value) continue; + + const ds_first: i32 = cross.date.days - start_date.days; + const ds_prev: ?i32 = if (prev_date) |pd| cross.date.days - pd.days else null; + + try out.append(allocator, .{ + .index = index, + .threshold = T, + .date = cross.date, + .days_since_prev = ds_prev, + .days_since_first = ds_first, + .is_start = false, + }); + index += 1; + prev_date = cross.date; + } + }, + .relative => |f| { + // Relative thresholds: emit the starting value as + // index 1 (synthetic), then start*f, start*f^2, ... + try out.append(allocator, .{ + .index = 1, + .threshold = start_value, + .date = start_date, + .days_since_prev = null, + .days_since_first = 0, + .is_start = true, + }); + index = 2; + prev_date = start_date; + + var T = start_value * f; + while (T <= max_value) { + const cross = findFirstAtOrAbove(series, T) orelse { + // Shouldn't happen given T <= max_value, but be safe. + break; + }; + + const ds_first: i32 = cross.date.days - start_date.days; + const ds_prev: ?i32 = if (prev_date) |pd| cross.date.days - pd.days else null; + + try out.append(allocator, .{ + .index = index, + .threshold = T, + .date = cross.date, + .days_since_prev = ds_prev, + .days_since_first = ds_first, + .is_start = false, + }); + index += 1; + prev_date = cross.date; + T *= f; + } + }, + } + + return out.toOwnedSlice(allocator); +} + +fn findFirstAtOrAbove(series: []const Point, threshold: f64) ?Point { + for (series) |p| { + if (p.value >= threshold) return p; + } + return null; +} + +// ── CPI deflation ──────────────────────────────────────────── + +/// Convert a nominal value at calendar year `from_year` to its +/// equivalent in `to_year` dollars, given a slice of year/CPI +/// pairs. The CPI series is the per-year inflation rate (as +/// from `shiller.annual_returns[i].cpi_inflation`). +/// +/// If `from_year == to_year`, returns `nominal` unchanged. +/// If `from_year > to_year`, deflates (compounds backward). +/// If `from_year < to_year`, inflates (compounds forward). +/// +/// Years outside the available CPI series are clamped to the +/// nearest endpoint (with the caller's responsibility to warn). +pub const YearCpi = struct { + year: u16, + cpi: f64, +}; + +pub fn deflate( + nominal: f64, + from_year: u16, + to_year: u16, + cpi: []const YearCpi, +) f64 { + if (cpi.len == 0) return nominal; + if (from_year == to_year) return nominal; + + var factor: f64 = 1.0; + if (from_year < to_year) { + // Inflate forward: multiply by (1 + cpi) for each year + // from `from_year` (inclusive) up to `to_year` (exclusive). + var y: u16 = from_year; + while (y < to_year) : (y += 1) { + factor *= (1.0 + cpiForYear(y, cpi)); + } + return nominal * factor; + } else { + // Deflate backward: divide by (1 + cpi) for each year + // from `to_year` (inclusive) up to `from_year` (exclusive). + var y: u16 = to_year; + while (y < from_year) : (y += 1) { + factor *= (1.0 + cpiForYear(y, cpi)); + } + return nominal / factor; + } +} + +fn cpiForYear(year: u16, cpi: []const YearCpi) f64 { + // Linear scan — small slice (<200 entries), and it's called + // O(years × series_length) times in the worst case which is + // still trivial. + if (year <= cpi[0].year) return cpi[0].cpi; + if (year >= cpi[cpi.len - 1].year) return cpi[cpi.len - 1].cpi; + for (cpi) |yc| { + if (yc.year == year) return yc.cpi; + } + return cpi[cpi.len - 1].cpi; +} + +// ── Tests ──────────────────────────────────────────────────── + +test "parseStep: absolute bare numbers" { + const a = try parseStep("1000000"); + try std.testing.expect(a == .absolute); + try std.testing.expectEqual(@as(f64, 1_000_000), a.absolute); + + const b = try parseStep("500"); + try std.testing.expect(b == .absolute); + try std.testing.expectEqual(@as(f64, 500), b.absolute); +} + +test "parseStep: K and M suffixes" { + const cases = [_]struct { in: []const u8, out: f64 }{ + .{ .in = "1M", .out = 1_000_000 }, + .{ .in = "1m", .out = 1_000_000 }, + .{ .in = "1.5M", .out = 1_500_000 }, + .{ .in = "500K", .out = 500_000 }, + .{ .in = "500k", .out = 500_000 }, + .{ .in = "0.5M", .out = 500_000 }, + }; + for (cases) |c| { + const s = try parseStep(c.in); + try std.testing.expect(s == .absolute); + try std.testing.expectEqual(c.out, s.absolute); + } +} + +test "parseStep: relative x suffix" { + const cases = [_]struct { in: []const u8, out: f64 }{ + .{ .in = "2x", .out = 2.0 }, + .{ .in = "2X", .out = 2.0 }, + .{ .in = "1.5x", .out = 1.5 }, + }; + for (cases) |c| { + const s = try parseStep(c.in); + try std.testing.expect(s == .relative); + try std.testing.expectEqual(c.out, s.relative); + } +} + +test "parseStep: rejects" { + try std.testing.expectError(error.Empty, parseStep("")); + try std.testing.expectError(error.NonProgressiveRelative, parseStep("1x")); + try std.testing.expectError(error.NonProgressiveRelative, parseStep("1.0x")); + try std.testing.expectError(error.NonProgressiveRelative, parseStep("0.5x")); + try std.testing.expectError(error.NonPositiveAbsolute, parseStep("0")); + try std.testing.expectError(error.NonPositiveAbsolute, parseStep("-1M")); + try std.testing.expectError(error.UnknownSuffix, parseStep("1G")); + try std.testing.expectError(error.UnknownSuffix, parseStep("100%")); + try std.testing.expectError(error.InvalidNumber, parseStep("2x.5")); + try std.testing.expectError(error.InvalidNumber, parseStep("M")); + try std.testing.expectError(error.InvalidNumber, parseStep("x")); + try std.testing.expectError(error.NonFiniteValue, parseStep("inf")); + try std.testing.expectError(error.NonFiniteValue, parseStep("infM")); + try std.testing.expectError(error.NonFiniteValue, parseStep("infx")); +} + +test "detectCrossings: empty series" { + const result = try detectCrossings(std.testing.allocator, &.{}, .{ .absolute = 1_000_000 }); + defer std.testing.allocator.free(result); + try std.testing.expectEqual(@as(usize, 0), result.len); +} + +test "detectCrossings: absolute mode, simple progression" { + // Series climbs from $1.28M to $7.0M over 12 years. + // $1M step: starting value already past, so first observable + // crossing is $2M. + const series = [_]Point{ + .{ .date = Date.fromYmd(2014, 7, 3), .value = 1_280_000 }, + .{ .date = Date.fromYmd(2017, 1, 1), .value = 2_100_000 }, + .{ .date = Date.fromYmd(2019, 1, 1), .value = 3_500_000 }, + .{ .date = Date.fromYmd(2024, 6, 1), .value = 7_000_000 }, + }; + + const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 }); + defer std.testing.allocator.free(result); + + // Expect crossings at $2M (2017-01-01), $3M (2019-01-01), + // $4M, $5M, $6M, $7M (all 2024-06-01 due to the jump). + try std.testing.expectEqual(@as(usize, 6), result.len); + try std.testing.expectEqual(@as(f64, 2_000_000), result[0].threshold); + try std.testing.expectEqual(Date.fromYmd(2017, 1, 1), result[0].date); + try std.testing.expectEqual(@as(f64, 3_000_000), result[1].threshold); + try std.testing.expectEqual(Date.fromYmd(2019, 1, 1), result[1].date); + // $4M, $5M, $6M, $7M all hit at 2024-06-01. + for (result[2..]) |c| { + try std.testing.expectEqual(Date.fromYmd(2024, 6, 1), c.date); + } + + // First crossing has null days_since_prev. + try std.testing.expectEqual(@as(?i32, null), result[0].days_since_prev); + // days_since_first is measured from the series start, not the + // first crossing. + const expected_first = Date.fromYmd(2017, 1, 1).days - Date.fromYmd(2014, 7, 3).days; + try std.testing.expectEqual(expected_first, result[0].days_since_first); +} + +test "detectCrossings: relative mode, doubling" { + // Synthetic doubling: 1M, 2M, 4M, 8M. + const series = [_]Point{ + .{ .date = Date.fromYmd(2014, 1, 1), .value = 1_000_000 }, + .{ .date = Date.fromYmd(2018, 1, 1), .value = 2_100_000 }, + .{ .date = Date.fromYmd(2022, 1, 1), .value = 4_200_000 }, + .{ .date = Date.fromYmd(2026, 1, 1), .value = 8_400_000 }, + }; + + const result = try detectCrossings(std.testing.allocator, &series, .{ .relative = 2.0 }); + defer std.testing.allocator.free(result); + + // 1x synthetic + 3 real crossings (2x, 4x, 8x). + try std.testing.expectEqual(@as(usize, 4), result.len); + try std.testing.expect(result[0].is_start); + try std.testing.expectEqual(@as(f64, 1_000_000), result[0].threshold); + try std.testing.expectEqual(@as(f64, 2_000_000), result[1].threshold); + try std.testing.expectEqual(@as(f64, 4_000_000), result[2].threshold); + try std.testing.expectEqual(@as(f64, 8_000_000), result[3].threshold); + + // days_since_prev is set on all but the first. + try std.testing.expectEqual(@as(?i32, null), result[0].days_since_prev); + try std.testing.expect(result[1].days_since_prev != null); + try std.testing.expect(result[1].days_since_prev.? > 0); +} + +test "detectCrossings: series shorter than first threshold" { + const series = [_]Point{ + .{ .date = Date.fromYmd(2014, 1, 1), .value = 100_000 }, + .{ .date = Date.fromYmd(2015, 1, 1), .value = 150_000 }, + }; + + const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 }); + defer std.testing.allocator.free(result); + try std.testing.expectEqual(@as(usize, 0), result.len); +} + +test "detectCrossings: relative mode with single point" { + const series = [_]Point{ + .{ .date = Date.fromYmd(2014, 1, 1), .value = 1_000_000 }, + }; + const result = try detectCrossings(std.testing.allocator, &series, .{ .relative = 2.0 }); + defer std.testing.allocator.free(result); + // Just the synthetic starting point. + try std.testing.expectEqual(@as(usize, 1), result.len); + try std.testing.expect(result[0].is_start); +} + +test "detectCrossings: multiple thresholds in single jump" { + // Big jump from $1M to $5M: $2M, $3M, $4M, $5M all hit at the + // same observation. + const series = [_]Point{ + .{ .date = Date.fromYmd(2014, 1, 1), .value = 1_000_000 }, + .{ .date = Date.fromYmd(2014, 1, 8), .value = 5_500_000 }, + }; + + const result = try detectCrossings(std.testing.allocator, &series, .{ .absolute = 1_000_000 }); + defer std.testing.allocator.free(result); + // $2M, $3M, $4M, $5M — all four at 2014-01-08. + try std.testing.expectEqual(@as(usize, 4), result.len); + for (result) |c| { + try std.testing.expectEqual(Date.fromYmd(2014, 1, 8), c.date); + } +} + +test "deflate: identity when from == to" { + const cpi = [_]YearCpi{ .{ .year = 2020, .cpi = 0.02 }, .{ .year = 2024, .cpi = 0.03 } }; + const v = deflate(1000, 2022, 2022, &cpi); + try std.testing.expectEqual(@as(f64, 1000), v); +} + +test "deflate: simple inflation forward" { + const cpi = [_]YearCpi{ + .{ .year = 2020, .cpi = 0.10 }, + .{ .year = 2021, .cpi = 0.10 }, + .{ .year = 2022, .cpi = 0.10 }, + }; + // From 2020 → 2023: 3 years of 10% compounds to 1.331x. + const v = deflate(1000, 2020, 2023, &cpi); + try std.testing.expectApproxEqAbs(1331.0, v, 0.01); +} + +test "deflate: simple deflation backward" { + const cpi = [_]YearCpi{ + .{ .year = 2020, .cpi = 0.10 }, + .{ .year = 2021, .cpi = 0.10 }, + .{ .year = 2022, .cpi = 0.10 }, + }; + // From 2023 → 2020 dollars: divide by 1.331. + const v = deflate(1331, 2023, 2020, &cpi); + try std.testing.expectApproxEqAbs(1000.0, v, 0.01); +} + +test "deflate: empty cpi returns nominal" { + const v = deflate(1000, 2020, 2024, &.{}); + try std.testing.expectEqual(@as(f64, 1000), v); +} diff --git a/src/analytics/timeline.zig b/src/analytics/timeline.zig index 946b5f1..9cbbb38 100644 --- a/src/analytics/timeline.zig +++ b/src/analytics/timeline.zig @@ -48,6 +48,14 @@ pub const TimelinePoint = struct { accounts: []const NamedValue, /// Per-tax-type totals. Same caveat as `accounts`. tax_types: []const NamedValue, + /// Where this point came from. `.snapshot` (default) means a + /// native `*-portfolio.srf` file; `.imported` means a + /// `imported_values.srf` row that was merged in. Imported + /// points carry only `liquid` (illiquid/net_worth are zero, + /// accounts/tax_types are empty). + source: Source = .snapshot, + + pub const Source = enum { snapshot, imported }; /// Deinit releases only the owned slices (`accounts`, `tax_types`). /// The strings inside those slices are borrowed from the source @@ -170,9 +178,78 @@ pub fn snapshotToPoint( .illiquid = ill, .accounts = accts, .tax_types = tts, + .source = .snapshot, }; } +// ── Merged series construction ─────────────────────────────── + +/// Build a TimelineSeries by merging native portfolio snapshots +/// with imported_values.srf rows. On overlapping dates, snapshots +/// take precedence (higher fidelity — they carry illiquid and +/// breakdowns). +/// +/// Imported-only points produce TimelinePoint records with +/// `liquid` set, `illiquid = 0`, `net_worth = liquid`, +/// `accounts = &.{}`, `tax_types = &.{}`, and `source = .imported`. +/// Callers that need to skip imported-only points (e.g. for +/// illiquid/net_worth rendering) inspect `source`. +/// +/// Returned series is sorted ascending by date. +pub fn buildMergedSeries( + allocator: std.mem.Allocator, + snapshots: []const snapshot.Snapshot, + imported: []const ImportedHistoryPoint, +) !TimelineSeries { + // First, collect the snapshot dates so we can skip imports + // that overlap. + var snapshot_days: std.AutoHashMap(i32, void) = .init(allocator); + defer snapshot_days.deinit(); + for (snapshots) |snap| { + try snapshot_days.put(snap.meta.as_of_date.days, {}); + } + + var points: std.ArrayList(TimelinePoint) = .empty; + errdefer { + for (points.items) |p| p.deinit(allocator); + points.deinit(allocator); + } + + // Snapshots: full-fidelity points. + for (snapshots) |snap| { + const tp = try snapshotToPoint(allocator, snap); + try points.append(allocator, tp); + } + + // Imports: only those whose date isn't already covered. + for (imported) |ip| { + if (snapshot_days.contains(ip.date.days)) continue; + try points.append(allocator, .{ + .as_of_date = ip.date, + .net_worth = ip.liquid, + .liquid = ip.liquid, + .illiquid = 0, + .accounts = &.{}, + .tax_types = &.{}, + .source = .imported, + }); + } + + const slice = try points.toOwnedSlice(allocator); + std.mem.sort(TimelinePoint, slice, {}, lessByDate); + + return .{ .points = slice, .allocator = allocator }; +} + +/// Minimal cross-module type for `buildMergedSeries`. Mirrors the +/// public shape of `data/imported_values.zig:HistoryPoint` without +/// pulling that module into the analytics layer (analytics is +/// kept import-light to stay testable in isolation). +pub const ImportedHistoryPoint = struct { + date: Date, + liquid: f64, +}; + // ── Filters ────────────────────────────────────────────────── /// Return the subset of `points` whose `as_of_date` falls within the @@ -380,6 +457,14 @@ pub const WindowStat = struct { /// `(end_value - start_value) / start_value`. Null when start is /// missing OR when start is exactly zero (division by zero). delta_pct: ?f64, + /// CAGR — annualized growth rate over the window: + /// `(end_value / start_value)^(1/years) - 1`. + /// Null when `start_value` is missing or zero, when + /// `years <= 0` (degenerate window), or when end/start ratio + /// is non-positive (which would require equity to go negative). + /// Years are computed as `(today.days - anchor.days) / 365.25`, + /// the standard CAGR convention. + annualized_pct: ?f64, }; /// Rolling-windows block for a single metric. Owns the `rows` slice. @@ -428,18 +513,21 @@ pub fn computeWindowSet( const target = period.targetDate(as_of); const anchor_opt = pointAtOrBefore(points, target); - rows[i] = if (anchor_opt) |a| .{ - .period = period, - .label = period.longLabel(), - .short_label = period.label(), - .anchor_date = a.as_of_date, - .start_value = extractValue(a.*, metric), - .end_value = end_value, - .delta_abs = end_value - extractValue(a.*, metric), - .delta_pct = blk: { - const sv = extractValue(a.*, metric); - break :blk if (sv == 0) null else (end_value - sv) / sv; - }, + rows[i] = if (anchor_opt) |a| blk_outer: { + const sv = extractValue(a.*, metric); + const dpct: ?f64 = if (sv == 0) null else (end_value - sv) / sv; + const apct: ?f64 = annualizedFromPct(dpct, a.as_of_date, as_of); + break :blk_outer .{ + .period = period, + .label = period.longLabel(), + .short_label = period.label(), + .anchor_date = a.as_of_date, + .start_value = sv, + .end_value = end_value, + .delta_abs = end_value - sv, + .delta_pct = dpct, + .annualized_pct = apct, + }; } else .{ .period = period, .label = period.longLabel(), @@ -449,6 +537,7 @@ pub fn computeWindowSet( .end_value = end_value, .delta_abs = null, .delta_pct = null, + .annualized_pct = null, }; } @@ -457,6 +546,7 @@ pub fn computeWindowSet( // doc block). const first = points[0]; const first_value = extractValue(first, metric); + const all_pct: ?f64 = if (first_value == 0) null else (end_value - first_value) / first_value; rows[windows.len] = .{ .period = null, .label = "All-time", @@ -465,12 +555,38 @@ pub fn computeWindowSet( .start_value = first_value, .end_value = end_value, .delta_abs = end_value - first_value, - .delta_pct = if (first_value == 0) null else (end_value - first_value) / first_value, + .delta_pct = all_pct, + .annualized_pct = annualizedFromPct(all_pct, first.as_of_date, as_of), }; return .{ .rows = rows, .allocator = allocator }; } +/// Compute the annualized return (CAGR) from a cumulative pct +/// over a window. Years are derived from raw day count divided +/// by 365.25 (standard CAGR convention). +/// +/// `as_of` is the reference end-date for the window — typically +/// the chart's "now" but the parameter accepts any caller-chosen +/// date (per AGENTS.md, `as_of` not `today` for arbitrary +/// caller-supplied reference dates). +/// +/// Returns null when: +/// - `delta_pct` is null (no anchor), +/// - `years <= 0` (degenerate / future-dated window), +/// - `1 + delta_pct <= 0` (would require equity to go negative +/// to losses exceeding 100% — impossible from positive start +/// equity, but defensive against bad input). +fn annualizedFromPct(delta_pct: ?f64, anchor: Date, as_of: Date) ?f64 { + const dpct = delta_pct orelse return null; + const day_diff: f64 = @floatFromInt(as_of.days - anchor.days); + if (day_diff <= 0) return null; + const years = day_diff / 365.25; + const ratio = 1.0 + dpct; + if (ratio <= 0) return null; + return std.math.pow(f64, ratio, 1.0 / years) - 1.0; +} + // ── Per-row day-over-day deltas ────────────────────────────── /// One row in the "Recent snapshots" table after per-row deltas have @@ -516,12 +632,17 @@ pub const Resolution = enum { daily, weekly, monthly, + /// Multi-tier cascade — daily, weekly, monthly, quarterly, + /// yearly. Produced by `aggregateCascading`, not by the + /// `aggregatePoints` flat-aggregation function. + cascading, pub fn label(self: Resolution) []const u8 { return switch (self) { .daily => "daily", .weekly => "weekly", .monthly => "monthly", + .cascading => "cascading", }; } }; @@ -570,6 +691,10 @@ pub fn aggregatePoints( }, .weekly => return aggregateWeeklyRolling(allocator, points), .monthly => return aggregateMonthly(allocator, points), + // Cascading is not a flat resolution; callers should use + // `aggregateCascading` directly. Emit an empty slice to + // keep this entry-point's invariants simple. + .cascading => return allocator.alloc(TimelinePoint, 0), } } @@ -647,6 +772,606 @@ fn aggregateMonthly( return picked.toOwnedSlice(allocator); } +// ── Cascading (multi-tier) aggregation ─────────────────────── + +/// One tier in the cascading view of recent history. Tag names +/// double as display labels — use `@tagName(t)` directly when +/// rendering. +pub const Tier = enum { + daily, + weekly, + monthly, + quarterly, + yearly, +}; + +/// One bucket in the cascading view. `representative_date` is +/// the date of the latest data point inside the bucket — the row +/// "represents" that point's values. `bucket_start` / `bucket_end` +/// describe the bucket's calendar range. +/// +/// `series_slice` is a non-owning view into the `series` passed +/// to `aggregateCascading` — the points that fell inside this +/// bucket's date range. Drilldown via `childBuckets` walks the +/// parent's slice directly. Empty buckets get an empty slice. +/// +/// Lifetime: the slice borrows from the caller-owned series, +/// which must outlive the `TieredSeries` and any drill-down +/// calls. In practice the series lives in `LoadedTimeline` (TUI) +/// or a local arena (CLI), both of which dominate any +/// `TieredSeries` produced from them. +pub const TierBucket = struct { + tier: Tier, + bucket_start: Date, + bucket_end: Date, + representative_date: Date, + liquid: f64, + illiquid: f64, + net_worth: f64, + /// True when every data point in the bucket came from + /// imported_values (no native snapshot landed inside it). + /// Renderers use this to gate `—` for illiquid/net_worth + /// cells and to skip plotting illiquid/net_worth in graphs. + imported_only: bool, + /// The contiguous slice of the source series that fell in + /// this bucket's date range. Empty for synthetic buckets + /// that span a date range with no data points. + series_slice: []const TimelinePoint, +}; + +/// Owned cascading-view result. Buckets are concatenated across +/// tiers in newest-first order: daily buckets, then weekly, then +/// monthly, quarterly, yearly. Tier transitions are inferred by +/// walking the slice and watching `bucket.tier` change. +pub const TieredSeries = struct { + buckets: []TierBucket, + allocator: std.mem.Allocator, + + pub fn deinit(self: TieredSeries) void { + self.allocator.free(self.buckets); + } +}; + +/// Per-bucket delta ("change since the older neighbor"). +/// +/// Buckets in `TieredSeries` are stored newest-first. The +/// "older neighbor" of bucket `i` is therefore `buckets[i+1]`. +/// `delta_*` on the OLDEST bucket (last in the slice) is null +/// — there's no older neighbor to compare against. +/// +/// `delta_illiquid` and `delta_net_worth` are also null when +/// either neighbor is `imported_only` (imported_values doesn't +/// carry illiquid, so a Δ across an imported boundary is +/// meaningless). +pub const BucketDelta = struct { + delta_liquid: ?f64, + delta_illiquid: ?f64, + delta_net_worth: ?f64, +}; + +/// Compute per-bucket deltas for a `TieredSeries`. Returns a +/// slice the same length as `series.buckets`, parallel-indexed. +/// +/// `delta_*` semantics: Δ on bucket `i` is `buckets[i].value - +/// buckets[i+1].value` (current minus older neighbor). Newest- +/// first iteration: positive Δ on a row = "the portfolio was +/// higher on this date than at the older neighbor's date" = +/// "we gained going forward in time." Matches the existing +/// `computeRowDeltas` convention for the flat-resolution table. +pub fn computeBucketDeltas( + allocator: std.mem.Allocator, + buckets: []const TierBucket, +) ![]BucketDelta { + const out = try allocator.alloc(BucketDelta, buckets.len); + for (buckets, 0..) |b, i| { + const older_idx = i + 1; + if (older_idx >= buckets.len) { + out[i] = .{ .delta_liquid = null, .delta_illiquid = null, .delta_net_worth = null }; + continue; + } + const older = buckets[older_idx]; + const dl: ?f64 = b.liquid - older.liquid; + // Illiquid / net_worth Δ is undefined when either + // neighbor lacks the data (imported_only buckets). + const cross_imported = b.imported_only or older.imported_only; + const di: ?f64 = if (cross_imported) null else b.illiquid - older.illiquid; + const dn: ?f64 = if (cross_imported) null else b.net_worth - older.net_worth; + out[i] = .{ .delta_liquid = dl, .delta_illiquid = di, .delta_net_worth = dn }; + } + return out; +} + +/// Build the cascading view from a date-ascending series. +/// `as_of` is the reference date — the daily tier covers +/// `[as_of.subDays(13), as_of]`. Pass `series[series.len-1].as_of_date` +/// for the typical case; pin a deterministic value in tests. +/// Per AGENTS.md: named `as_of` (not `today`) because callers +/// can legitimately pass any date — for back-dated views, the +/// reference is whatever the user asked for, not the calendar +/// day. +/// +/// The tier coverage rules implement the user-facing intent: +/// always show at least one bucket of each granularity that has +/// any data, and never skip a tier when transitioning from finer +/// to coarser. +/// +/// **Daily:** every point in `[as_of.subDays(13), as_of]`. +/// +/// **Weekly:** weeks (Monday→Sunday) ending strictly before the +/// daily tier's earliest covered date, going back 4 weeks. Buckets +/// with zero data are skipped. +/// +/// **Monthly:** calendar months ending before the weekly tier's +/// earliest covered date, walking back to Jan 1 of `as_of.year()`. +/// If that's fewer than 3 months, extend backward into the prior +/// year so at least 3 months are emitted (when data exists). +/// +/// **Quarterly:** the most recent prior calendar year (typically +/// `as_of.year() - 1`). Up to 4 quarters newest-first. +/// +/// **Yearly:** every calendar year before the quarterly tier's +/// year that has any data, newest-first. +pub fn aggregateCascading( + allocator: std.mem.Allocator, + series: []const TimelinePoint, + as_of: Date, +) !TieredSeries { + if (series.len == 0) { + return .{ .buckets = &.{}, .allocator = allocator }; + } + + // Compute the tier boundaries. + // + // Daily: every point in `[daily_start, as_of]`. Each becomes + // its own per-point bucket (no aggregation). + // Weekly: 4 buckets ending at `weekly_end_initial`, walking + // back 7 days at a time. + // Monthly: calendar months of `as_of.year()`, from Jan to + // `monthly_boundary.month()`. Skipped when the boundary + // falls before Jan of the as-of year. + // Quarterly: 4 calendar quarters of `as_of.year() - 1`. + // Yearly: every calendar year before `quarterly_year`, + // from `series[0].as_of_date.year()` up to `quarterly_year - 1`. + // + // We pre-build a flat frame of non-daily buckets in + // chronological (oldest-first) order. Then we make a single + // forward pass through `series`, advancing a cursor in the + // frame as point dates cross bucket boundaries. Each bucket + // remembers its latest in-range point. + const daily_start = as_of.addDays(-13); + const weekly_end_initial = daily_start.addDays(-1); + const monthly_boundary = weekly_end_initial.addDays(-28); + const as_of_year = as_of.year(); + const quarterly_year: i16 = as_of_year - 1; + + // ── Build the non-daily frame, oldest-first ────────────── + // + // Order: yearly (earliest..latest) → quarterly (Q1..Q4) → + // monthly (Jan..boundary month) → weekly (oldest..newest of + // the 4 weeks before the daily tier). + // + // This keeps frame entries strictly date-ascending. As we + // walk the series (also date-ascending), the bucket cursor + // only ever moves forward. + var frame: std.ArrayList(BucketFrame) = .empty; + defer frame.deinit(allocator); + + // Yearly buckets. + { + const first_y = series[0].as_of_date.year(); + if (first_y < quarterly_year) { + var y = first_y; + while (y < quarterly_year) : (y += 1) { + try frame.append(allocator, .{ + .tier = .yearly, + .bucket_start = Date.fromYmd(y, 1, 1), + .bucket_end = Date.fromYmd(y, 12, 31), + .latest = null, + .any_snapshot = false, + .series_start = 0, + .series_end = 0, + }); + } + } + } + + // Quarterly buckets — Q1..Q4 of quarterly_year. + { + var q: u8 = 1; + while (q <= 4) : (q += 1) { + const start_month: u8 = (q - 1) * 3 + 1; + const end_month: u8 = q * 3; + try frame.append(allocator, .{ + .tier = .quarterly, + .bucket_start = Date.fromYmd(quarterly_year, start_month, 1), + .bucket_end = Date.lastDayOfMonth(quarterly_year, end_month), + .latest = null, + .any_snapshot = false, + .series_start = 0, + .series_end = 0, + }); + } + } + + // Monthly buckets — Jan..monthly_boundary.month() of + // as_of_year. Skip entirely if the boundary precedes the + // year (e.g. very early in January). + if (monthly_boundary.year() == as_of_year) { + const max_month = monthly_boundary.month(); + var m: u8 = 1; + while (m <= max_month) : (m += 1) { + try frame.append(allocator, .{ + .tier = .monthly, + .bucket_start = Date.fromYmd(as_of_year, m, 1), + .bucket_end = Date.lastDayOfMonth(as_of_year, m), + .latest = null, + .any_snapshot = false, + .series_start = 0, + .series_end = 0, + }); + } + } + + // Weekly buckets — 4 weeks ending at weekly_end_initial. + // Build oldest-first to maintain frame ascending order. + { + var w: i32 = 3; + while (w >= 0) : (w -= 1) { + const end = weekly_end_initial.addDays(-7 * w); + const start = end.addDays(-6); + try frame.append(allocator, .{ + .tier = .weekly, + .bucket_start = start, + .bucket_end = end, + .latest = null, + .any_snapshot = false, + .series_start = 0, + .series_end = 0, + }); + if (w == 0) break; + } + } + + // ── Single forward pass through the series ─────────────── + // + // Maintain `cursor` into `frame`. For each point: + // - If point.date > as_of: skip (future-dated; rare). + // - If point.date >= daily_start: daily tier; remember + // for later (we emit dailies newest-first at the end). + // - Else advance cursor while point.date > frame[cursor].bucket_end. + // If point.date >= frame[cursor].bucket_start, update + // that bucket's `latest`, `any_snapshot`, and series- + // index range. Else the point falls in a gap between + // frame entries (rare; e.g. older than the earliest + // yearly bucket) — drop it. + // + // Each frame bucket's `series_start` / `series_end` end up + // forming the half-open slice of `series` that fell in its + // calendar range. `series_start` is set on the first hit; + // `series_end` is bumped to `idx + 1` on every hit. Empty + // buckets keep `series_start == series_end == 0`, which is + // a benign empty slice. + const DailyPoint = struct { p: TimelinePoint, idx: usize }; + var daily_points: std.ArrayList(DailyPoint) = .empty; + defer daily_points.deinit(allocator); + + var cursor: usize = 0; + for (series, 0..) |p, idx| { + if (p.as_of_date.days > as_of.days) continue; + if (p.as_of_date.days >= daily_start.days) { + try daily_points.append(allocator, .{ .p = p, .idx = idx }); + continue; + } + // Advance the cursor until the current frame entry could + // contain `p`. + while (cursor < frame.items.len and p.as_of_date.days > frame.items[cursor].bucket_end.days) { + cursor += 1; + } + if (cursor >= frame.items.len) break; // no more buckets ahead + const fb = &frame.items[cursor]; + if (p.as_of_date.days < fb.bucket_start.days) { + // Point is in a gap between buckets (e.g. point falls + // in the year before the earliest yearly bucket — but + // by construction yearly starts at series[0].year, so + // this only happens for points that didn't fit any + // tier's date range). Skip. + continue; + } + if (fb.latest == null) fb.series_start = idx; + fb.series_end = idx + 1; + fb.latest = p; + if (p.source == .snapshot) fb.any_snapshot = true; + } + + // ── Assemble output, newest-first ──────────────────────── + var out: std.ArrayList(TierBucket) = .empty; + errdefer out.deinit(allocator); + + // Daily points: collected oldest-first; emit newest-first. + var di: usize = daily_points.items.len; + while (di > 0) { + di -= 1; + const dp = daily_points.items[di]; + try out.append(allocator, .{ + .tier = .daily, + .bucket_start = dp.p.as_of_date, + .bucket_end = dp.p.as_of_date, + .representative_date = dp.p.as_of_date, + .liquid = dp.p.liquid, + .illiquid = dp.p.illiquid, + .net_worth = dp.p.net_worth, + .imported_only = dp.p.source == .imported, + .series_slice = series[dp.idx .. dp.idx + 1], + }); + } + + // Non-daily buckets: frame is oldest-first; emit newest-first. + var fi: usize = frame.items.len; + while (fi > 0) { + fi -= 1; + const fb = frame.items[fi]; + const p = fb.latest orelse continue; + try out.append(allocator, .{ + .tier = fb.tier, + .bucket_start = fb.bucket_start, + .bucket_end = fb.bucket_end, + .representative_date = p.as_of_date, + .liquid = p.liquid, + .illiquid = p.illiquid, + .net_worth = p.net_worth, + .imported_only = !fb.any_snapshot, + .series_slice = series[fb.series_start..fb.series_end], + }); + } + + return .{ .buckets = try out.toOwnedSlice(allocator), .allocator = allocator }; +} + +/// Mutable bucket-accumulator state used during the single +/// forward pass in `aggregateCascading` and `childBuckets`. +/// `latest` holds the most recent in-range point seen so far; +/// `any_snapshot` flips on the first snapshot-sourced point +/// that lands in this bucket. `series_start` / `series_end` +/// track the half-open index range into whatever series was +/// being walked at the time — used to construct the bucket's +/// final `series_slice` at emit time. +const BucketFrame = struct { + tier: Tier, + bucket_start: Date, + bucket_end: Date, + latest: ?TimelinePoint, + any_snapshot: bool, + series_start: usize, + series_end: usize, +}; + +// ── Hierarchical drill-down ────────────────────────────────── +// +// The cascading view's recent-snapshots table renders each +// bucket as its own row; clicking a bucket reveals its child +// buckets at the next-finer tier (a yearly bucket reveals +// quarterlies, a quarterly reveals monthlies, etc.). + +/// Return the next-finer tier for `tier`, or null if there's no +/// finer tier (daily is the leaf). +pub fn finerTier(tier: Tier) ?Tier { + return switch (tier) { + .yearly => .quarterly, + .quarterly => .monthly, + .monthly => .weekly, + .weekly => .daily, + .daily => null, + }; +} + +/// Render a tier-aware label for a bucket. Caller passes the +/// bucket's tier and `bucket_start` date; the function emits +/// the appropriate display label into `buf` and returns a +/// slice of it. Shared by CLI and TUI cascading renderers. +/// +/// - daily: `YYYY-MM-DD` +/// - weekly: `W of YYYY-MM-DD` (start-of-week) +/// - monthly: `MMM YYYY` +/// - quarterly: `QN YYYY` +/// - yearly: `YYYY` +/// +/// `buf` should be at least 16 bytes for the longest label +/// ("W of YYYY-MM-DD" = 15). Returns `"?"` if `bufPrint` fails. +pub fn formatBucketLabel(buf: []u8, tier: Tier, bucket_start: Date) []const u8 { + var date_inner: [10]u8 = undefined; + return switch (tier) { + .daily => std.fmt.bufPrint(buf, "{s}", .{bucket_start.format(&date_inner)}) catch "?", + .weekly => std.fmt.bufPrint(buf, "W of {s}", .{bucket_start.format(&date_inner)}) catch "?", + .monthly => std.fmt.bufPrint(buf, "{s} {d}", .{ Date.monthShort(bucket_start.month()), bucket_start.year() }) catch "?", + .quarterly => std.fmt.bufPrint(buf, "Q{d} {d}", .{ ((bucket_start.month() - 1) / 3) + 1, bucket_start.year() }) catch "?", + .yearly => std.fmt.bufPrint(buf, "{d}", .{bucket_start.year()}) catch "?", + }; +} + +/// Build the child buckets contained inside `parent`. Returns +/// an owned slice, newest-first. Empty slice when `parent.tier +/// == .daily` (no children) or no data falls inside `parent`'s +/// date range. +/// +/// The granularity of children is determined by `finerTier`: +/// yearly → quarterly children (Q4..Q1 of that year) +/// quarterly → monthly children (last month..first month) +/// monthly → weekly children (calendar-aligned weeks ending +/// within the month, newest-first) +/// weekly → daily children (every data point in the 7-day range) +pub fn childBuckets( + allocator: std.mem.Allocator, + parent: TierBucket, +) ![]TierBucket { + var out: std.ArrayList(TierBucket) = .empty; + errdefer out.deinit(allocator); + + // Daily parents have no children. + if (parent.tier == .daily) return out.toOwnedSlice(allocator); + + // The parent's contents are already pinned in + // `parent.series_slice` — recorded by `aggregateCascading` + // when the bucket was emitted. No re-scan required. + const sub = parent.series_slice; + + // Weekly parent → daily children. Each in-range point becomes + // its own bucket. Walk `sub` newest-first directly. + if (parent.tier == .weekly) { + var i: usize = sub.len; + while (i > 0) { + i -= 1; + const p = sub[i]; + try out.append(allocator, .{ + .tier = .daily, + .bucket_start = p.as_of_date, + .bucket_end = p.as_of_date, + .representative_date = p.as_of_date, + .liquid = p.liquid, + .illiquid = p.illiquid, + .net_worth = p.net_worth, + .imported_only = p.source == .imported, + .series_slice = sub[i .. i + 1], + }); + } + return out.toOwnedSlice(allocator); + } + + // Monthly / quarterly / yearly parents: build a frame of + // children in chronological (oldest-first) order, walk the + // parent's `sub` slice once, bin each point into the current + // child via a forward-only cursor. Emit non-empty buckets + // newest-first at the end. + var frame: std.ArrayList(BucketFrame) = .empty; + defer frame.deinit(allocator); + + const child_tier = finerTier(parent.tier).?; + + switch (parent.tier) { + .monthly => { + // Weekly children: 7-day buckets ending at the last + // day of the parent month, walking back. Build them + // in oldest-first order so the frame stays ascending. + const month_start = parent.bucket_start; + const month_end = parent.bucket_end; + + // Generate week boundaries newest-first (anchored at + // month_end) into a temporary, then reverse into the + // frame. + var weeks_newest_first: std.ArrayList(struct { start: Date, end: Date }) = .empty; + defer weeks_newest_first.deinit(allocator); + var bucket_end_w = month_end; + while (bucket_end_w.days >= month_start.days) { + const bucket_start_raw = bucket_end_w.addDays(-6); + const bucket_start_w = if (bucket_start_raw.days < month_start.days) month_start else bucket_start_raw; + try weeks_newest_first.append(allocator, .{ .start = bucket_start_w, .end = bucket_end_w }); + if (bucket_start_w.days <= month_start.days) break; + bucket_end_w = bucket_start_w.addDays(-1); + } + // Reverse into frame (oldest-first). + var wi: usize = weeks_newest_first.items.len; + while (wi > 0) { + wi -= 1; + const w = weeks_newest_first.items[wi]; + try frame.append(allocator, .{ + .tier = .weekly, + .bucket_start = w.start, + .bucket_end = w.end, + .latest = null, + .any_snapshot = false, + .series_start = 0, + .series_end = 0, + }); + } + }, + .quarterly => { + // Monthly children: 3 calendar months in the parent + // quarter, oldest-first. + const start_month = parent.bucket_start.month(); + const year = parent.bucket_start.year(); + var off: u8 = 0; + while (off < 3) : (off += 1) { + const month: u8 = start_month + off; + try frame.append(allocator, .{ + .tier = .monthly, + .bucket_start = Date.fromYmd(year, month, 1), + .bucket_end = Date.lastDayOfMonth(year, month), + .latest = null, + .any_snapshot = false, + .series_start = 0, + .series_end = 0, + }); + } + }, + .yearly => { + // Quarterly children: Q1..Q4 of the parent year, + // oldest-first. + const year = parent.bucket_start.year(); + var q: u8 = 1; + while (q <= 4) : (q += 1) { + const start_month: u8 = (q - 1) * 3 + 1; + const end_month: u8 = q * 3; + try frame.append(allocator, .{ + .tier = .quarterly, + .bucket_start = Date.fromYmd(year, start_month, 1), + .bucket_end = Date.lastDayOfMonth(year, end_month), + .latest = null, + .any_snapshot = false, + .series_start = 0, + .series_end = 0, + }); + } + }, + // Already handled above. + .daily, .weekly => unreachable, + } + + // Single forward pass over the parent's slice. No "skip + // until enter / break when leave" — every point in `sub` + // is by construction inside the parent's date range. + // + // Frame indices (`series_start`/`series_end`) are recorded + // *relative to `sub`* during the pass, then sliced into + // `sub` at emit time so each child's `series_slice` is a + // sub-view of the parent's slice. + var cursor: usize = 0; + for (sub, 0..) |p, sub_idx| { + // Advance the child-cursor until the current frame entry + // contains this point. Frame entries are non-overlapping + // and oldest-first within the parent range. + while (cursor < frame.items.len and p.as_of_date.days > frame.items[cursor].bucket_end.days) { + cursor += 1; + } + if (cursor >= frame.items.len) break; + const fb = &frame.items[cursor]; + if (p.as_of_date.days < fb.bucket_start.days) continue; // gap (shouldn't happen for these tiers) + if (fb.latest == null) fb.series_start = sub_idx; + fb.series_end = sub_idx + 1; + fb.latest = p; + if (p.source == .snapshot) fb.any_snapshot = true; + } + + // Emit non-empty children newest-first. + var fi: usize = frame.items.len; + while (fi > 0) { + fi -= 1; + const fb = frame.items[fi]; + const p = fb.latest orelse continue; + try out.append(allocator, .{ + .tier = child_tier, + .bucket_start = fb.bucket_start, + .bucket_end = fb.bucket_end, + .representative_date = p.as_of_date, + .liquid = p.liquid, + .illiquid = p.illiquid, + .net_worth = p.net_worth, + .imported_only = !fb.any_snapshot, + .series_slice = sub[fb.series_start..fb.series_end], + }); + } + + return out.toOwnedSlice(allocator); +} + // ── Tests ──────────────────────────────────────────────────── // // Pure compute — every function here can be exercised with fixture @@ -785,6 +1510,86 @@ test "buildSeries: sorts ascending by date" { try testing.expectEqual(@as(f64, 3000), series.points[2].net_worth); } +test "buildMergedSeries: empty inputs" { + const series = try buildMergedSeries(testing.allocator, &.{}, &.{}); + defer series.deinit(); + try testing.expectEqual(@as(usize, 0), series.points.len); +} + +test "buildMergedSeries: imported only" { + const imp = [_]ImportedHistoryPoint{ + .{ .date = Date.fromYmd(2014, 7, 3), .liquid = 1_280_000 }, + .{ .date = Date.fromYmd(2014, 7, 10), .liquid = 1_273_000 }, + }; + const series = try buildMergedSeries(testing.allocator, &.{}, &imp); + defer series.deinit(); + try testing.expectEqual(@as(usize, 2), series.points.len); + try testing.expect(series.points[0].as_of_date.eql(Date.fromYmd(2014, 7, 3))); + try testing.expectEqual(@as(f64, 1_280_000), series.points[0].liquid); + try testing.expectEqual(@as(f64, 0), series.points[0].illiquid); + try testing.expectEqual(@as(f64, 1_280_000), series.points[0].net_worth); + try testing.expect(series.points[0].source == .imported); +} + +test "buildMergedSeries: snapshots only" { + var b1: [3]snapshot.TotalRow = undefined; + var b2: [3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&b1, 2026, 4, 20, 9_000_000, 8_000_000, 1_000_000), + fixtureSnapshot(&b2, 2026, 4, 21, 9_100_000, 8_100_000, 1_000_000), + }; + const series = try buildMergedSeries(testing.allocator, &snaps, &.{}); + defer series.deinit(); + try testing.expectEqual(@as(usize, 2), series.points.len); + try testing.expect(series.points[0].source == .snapshot); + try testing.expectEqual(@as(f64, 1_000_000), series.points[0].illiquid); +} + +test "buildMergedSeries: snapshot wins on overlap" { + var b1: [3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&b1, 2025, 6, 1, 5_000_000, 4_500_000, 500_000), + }; + const imp = [_]ImportedHistoryPoint{ + // Overlapping date — snapshot wins. + .{ .date = Date.fromYmd(2025, 6, 1), .liquid = 4_400_000 }, + // Non-overlapping — kept. + .{ .date = Date.fromYmd(2025, 5, 25), .liquid = 4_300_000 }, + }; + const series = try buildMergedSeries(testing.allocator, &snaps, &imp); + defer series.deinit(); + try testing.expectEqual(@as(usize, 2), series.points.len); + // Sorted ascending: 2025-05-25 first, then 2025-06-01. + try testing.expect(series.points[0].as_of_date.eql(Date.fromYmd(2025, 5, 25))); + try testing.expect(series.points[0].source == .imported); + try testing.expectEqual(@as(f64, 4_300_000), series.points[0].liquid); + try testing.expect(series.points[1].as_of_date.eql(Date.fromYmd(2025, 6, 1))); + try testing.expect(series.points[1].source == .snapshot); + // Snapshot value, not imported value. + try testing.expectEqual(@as(f64, 4_500_000), series.points[1].liquid); + try testing.expectEqual(@as(f64, 500_000), series.points[1].illiquid); +} + +test "buildMergedSeries: result is sorted ascending" { + var b1: [3]snapshot.TotalRow = undefined; + var b2: [3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&b1, 2026, 4, 20, 9_000_000, 8_000_000, 1_000_000), + fixtureSnapshot(&b2, 2014, 8, 1, 1_300_000, 1_300_000, 0), + }; + const imp = [_]ImportedHistoryPoint{ + .{ .date = Date.fromYmd(2020, 1, 15), .liquid = 3_000_000 }, + .{ .date = Date.fromYmd(2014, 7, 3), .liquid = 1_280_000 }, + }; + const series = try buildMergedSeries(testing.allocator, &snaps, &imp); + defer series.deinit(); + try testing.expectEqual(@as(usize, 4), series.points.len); + try testing.expect(series.points[0].as_of_date.eql(Date.fromYmd(2014, 7, 3))); + try testing.expect(series.points[1].as_of_date.eql(Date.fromYmd(2014, 8, 1))); + try testing.expect(series.points[2].as_of_date.eql(Date.fromYmd(2020, 1, 15))); + try testing.expect(series.points[3].as_of_date.eql(Date.fromYmd(2026, 4, 20))); +} + test "filterByDate: inclusive bounds" { var b1: [3]snapshot.TotalRow = undefined; var b2: [3]snapshot.TotalRow = undefined; @@ -1372,3 +2177,421 @@ test "aggregatePoints: empty input returns empty slice" { defer testing.allocator.free(out_m); try testing.expectEqual(@as(usize, 0), out_m.len); } + +test "aggregateCascading: empty series" { + const ts = try aggregateCascading(testing.allocator, &.{}, Date.fromYmd(2026, 5, 11)); + defer ts.deinit(); + try testing.expectEqual(@as(usize, 0), ts.buckets.len); +} + +test "aggregateCascading: daily tier covers last 14 days" { + var bufs: [10][3]snapshot.TotalRow = undefined; + var snaps_arr: [10]snapshot.Snapshot = undefined; + // Snapshots from 2026-04-30 through 2026-05-09 (10 days). + snaps_arr[0] = fixtureSnapshot(&bufs[0], 2026, 4, 30, 100, 100, 0); + snaps_arr[1] = fixtureSnapshot(&bufs[1], 2026, 5, 1, 110, 110, 0); + snaps_arr[2] = fixtureSnapshot(&bufs[2], 2026, 5, 2, 120, 120, 0); + snaps_arr[3] = fixtureSnapshot(&bufs[3], 2026, 5, 3, 130, 130, 0); + snaps_arr[4] = fixtureSnapshot(&bufs[4], 2026, 5, 4, 140, 140, 0); + snaps_arr[5] = fixtureSnapshot(&bufs[5], 2026, 5, 5, 150, 150, 0); + snaps_arr[6] = fixtureSnapshot(&bufs[6], 2026, 5, 6, 160, 160, 0); + snaps_arr[7] = fixtureSnapshot(&bufs[7], 2026, 5, 7, 170, 170, 0); + snaps_arr[8] = fixtureSnapshot(&bufs[8], 2026, 5, 8, 180, 180, 0); + snaps_arr[9] = fixtureSnapshot(&bufs[9], 2026, 5, 9, 190, 190, 0); + const series = try buildSeries(testing.allocator, &snaps_arr); + defer series.deinit(); + const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 5, 11)); + defer ts.deinit(); + + // All 10 should land in the daily tier (days 04-30 to 05-09 are within 13 days of 05-11). + var daily_count: usize = 0; + for (ts.buckets) |b| if (b.tier == .daily) { + daily_count += 1; + }; + try testing.expectEqual(@as(usize, 10), daily_count); + + // Newest-first: first daily bucket is 2026-05-09. + try testing.expect(ts.buckets[0].representative_date.eql(Date.fromYmd(2026, 5, 9))); + try testing.expectEqual(@as(f64, 190), ts.buckets[0].liquid); +} + +test "aggregateCascading: weekly tier emitted for older data" { + var bufs: [3][3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + // Outside daily (>13 days before 2026-05-11): should land in weekly. + fixtureSnapshot(&bufs[0], 2026, 4, 27, 100, 100, 0), + fixtureSnapshot(&bufs[1], 2026, 4, 20, 110, 110, 0), + fixtureSnapshot(&bufs[2], 2026, 4, 13, 120, 120, 0), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 5, 11)); + defer ts.deinit(); + + var weekly_count: usize = 0; + for (ts.buckets) |b| if (b.tier == .weekly) { + weekly_count += 1; + }; + try testing.expect(weekly_count >= 1); +} + +test "aggregateCascading: imported-only buckets flagged" { + const imp = [_]ImportedHistoryPoint{ + .{ .date = Date.fromYmd(2014, 7, 3), .liquid = 1_280_000 }, + .{ .date = Date.fromYmd(2014, 12, 26), .liquid = 1_400_000 }, + .{ .date = Date.fromYmd(2024, 6, 1), .liquid = 5_000_000 }, + }; + const series = try buildMergedSeries(testing.allocator, &.{}, &imp); + defer series.deinit(); + const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 5, 11)); + defer ts.deinit(); + + // Yearly buckets for 2014, 2024 should exist and be imported_only. + var found_2014 = false; + for (ts.buckets) |b| { + if (b.tier == .yearly and b.bucket_start.year() == 2014) { + found_2014 = true; + try testing.expect(b.imported_only); + try testing.expectEqual(@as(f64, 1_400_000), b.liquid); + try testing.expectEqual(@as(f64, 0), b.illiquid); + } + } + try testing.expect(found_2014); +} + +test "aggregateCascading: yearly buckets are newest-first" { + const imp = [_]ImportedHistoryPoint{ + .{ .date = Date.fromYmd(2020, 6, 1), .liquid = 3_000_000 }, + .{ .date = Date.fromYmd(2018, 6, 1), .liquid = 2_500_000 }, + .{ .date = Date.fromYmd(2016, 6, 1), .liquid = 2_000_000 }, + .{ .date = Date.fromYmd(2014, 6, 1), .liquid = 1_500_000 }, + }; + const series = try buildMergedSeries(testing.allocator, &.{}, &imp); + defer series.deinit(); + const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 5, 11)); + defer ts.deinit(); + + // Filter to yearly buckets and confirm descending year order. + var prev_year: ?i16 = null; + for (ts.buckets) |b| { + if (b.tier != .yearly) continue; + if (prev_year) |py| { + try testing.expect(b.bucket_start.year() < py); + } + prev_year = b.bucket_start.year(); + } +} + +test "aggregateCascading: monthly stays in current year only" { + var bufs: [4][3]snapshot.TotalRow = undefined; + // today = 2026-02-15. Current calendar year (2026) has data + // only for Jan and Feb. Monthly should NOT dip into 2025; + // the quarterly tier handles prior-year buckets. + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&bufs[0], 2025, 11, 28, 100, 100, 0), + fixtureSnapshot(&bufs[1], 2025, 12, 26, 110, 110, 0), + fixtureSnapshot(&bufs[2], 2026, 1, 30, 120, 120, 0), + fixtureSnapshot(&bufs[3], 2026, 2, 13, 130, 130, 0), // outside daily (today=2026-02-15, daily_start=2026-02-02), so this is weekly+ + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + const ts = try aggregateCascading(testing.allocator, series.points, Date.fromYmd(2026, 2, 15)); + defer ts.deinit(); + + var has_jan_2026 = false; + var has_dec_2025 = false; + var has_q4_2025 = false; + for (ts.buckets) |b| { + if (b.tier == .monthly and b.bucket_start.year() == 2026 and b.bucket_start.month() == 1) has_jan_2026 = true; + if (b.tier == .monthly and b.bucket_start.year() == 2025) has_dec_2025 = true; + if (b.tier == .quarterly and b.bucket_start.year() == 2025 and b.bucket_start.month() == 10) has_q4_2025 = true; + } + try testing.expect(has_jan_2026); + try testing.expect(!has_dec_2025); // monthly does NOT dip into 2025 + try testing.expect(has_q4_2025); +} + +test "computeBucketDeltas: Δ on row i is current minus older neighbor" { + const buckets = [_]TierBucket{ + .{ // Newest first. + .tier = .yearly, + .bucket_start = Date.fromYmd(2024, 1, 1), + .bucket_end = Date.fromYmd(2024, 12, 31), + .representative_date = Date.fromYmd(2024, 12, 31), + .liquid = 6_000_000, + .illiquid = 0, + .net_worth = 6_000_000, + .imported_only = true, + .series_slice = &.{}, + }, + .{ + .tier = .yearly, + .bucket_start = Date.fromYmd(2023, 1, 1), + .bucket_end = Date.fromYmd(2023, 12, 31), + .representative_date = Date.fromYmd(2023, 12, 31), + .liquid = 5_000_000, + .illiquid = 0, + .net_worth = 5_000_000, + .imported_only = true, + .series_slice = &.{}, + }, + .{ + .tier = .yearly, + .bucket_start = Date.fromYmd(2022, 1, 1), + .bucket_end = Date.fromYmd(2022, 12, 31), + .representative_date = Date.fromYmd(2022, 12, 31), + .liquid = 4_000_000, + .illiquid = 0, + .net_worth = 4_000_000, + .imported_only = true, + .series_slice = &.{}, + }, + }; + const deltas = try computeBucketDeltas(testing.allocator, &buckets); + defer testing.allocator.free(deltas); + + // Row 0 (2024) Δ vs row 1 (2023): +1M (gained from 2023 to 2024). + try testing.expectEqual(@as(?f64, 1_000_000), deltas[0].delta_liquid); + // Row 1 (2023) Δ vs row 2 (2022): +1M. + try testing.expectEqual(@as(?f64, 1_000_000), deltas[1].delta_liquid); + // Row 2 (2022): oldest, no neighbor. + try testing.expectEqual(@as(?f64, null), deltas[2].delta_liquid); + // illiquid Δ across imported_only neighbors → null. + try testing.expectEqual(@as(?f64, null), deltas[0].delta_illiquid); +} + +test "childBuckets: yearly produces 4 quarterly children" { + const imp = [_]ImportedHistoryPoint{ + .{ .date = Date.fromYmd(2024, 2, 28), .liquid = 1_000_000 }, + .{ .date = Date.fromYmd(2024, 5, 31), .liquid = 2_000_000 }, + .{ .date = Date.fromYmd(2024, 8, 30), .liquid = 3_000_000 }, + .{ .date = Date.fromYmd(2024, 11, 29), .liquid = 4_000_000 }, + }; + const series = try buildMergedSeries(testing.allocator, &.{}, &imp); + defer series.deinit(); + + const parent: TierBucket = .{ + .tier = .yearly, + .bucket_start = Date.fromYmd(2024, 1, 1), + .bucket_end = Date.fromYmd(2024, 12, 31), + .representative_date = Date.fromYmd(2024, 11, 29), + .liquid = 4_000_000, + .illiquid = 0, + .net_worth = 4_000_000, + .imported_only = true, + .series_slice = series.points, + }; + + const children = try childBuckets(testing.allocator, parent); + defer testing.allocator.free(children); + + try testing.expectEqual(@as(usize, 4), children.len); + // Newest-first: Q4, Q3, Q2, Q1. + try testing.expect(children[0].tier == .quarterly); + try testing.expect(children[0].bucket_start.eql(Date.fromYmd(2024, 10, 1))); + try testing.expect(children[3].bucket_start.eql(Date.fromYmd(2024, 1, 1))); + // Q4's value comes from the latest 2024-11-29 datum. + try testing.expectEqual(@as(f64, 4_000_000), children[0].liquid); +} + +test "childBuckets: quarterly produces 3 monthly children" { + const imp = [_]ImportedHistoryPoint{ + .{ .date = Date.fromYmd(2024, 1, 31), .liquid = 1_000_000 }, + .{ .date = Date.fromYmd(2024, 2, 28), .liquid = 2_000_000 }, + .{ .date = Date.fromYmd(2024, 3, 31), .liquid = 3_000_000 }, + }; + const series = try buildMergedSeries(testing.allocator, &.{}, &imp); + defer series.deinit(); + + const parent: TierBucket = .{ + .tier = .quarterly, + .bucket_start = Date.fromYmd(2024, 1, 1), + .bucket_end = Date.fromYmd(2024, 3, 31), + .representative_date = Date.fromYmd(2024, 3, 31), + .liquid = 3_000_000, + .illiquid = 0, + .net_worth = 3_000_000, + .imported_only = true, + .series_slice = series.points, + }; + + const children = try childBuckets(testing.allocator, parent); + defer testing.allocator.free(children); + + try testing.expectEqual(@as(usize, 3), children.len); + // Newest-first: Mar, Feb, Jan. + try testing.expect(children[0].bucket_start.eql(Date.fromYmd(2024, 3, 1))); + try testing.expect(children[2].bucket_start.eql(Date.fromYmd(2024, 1, 1))); +} + +test "childBuckets: weekly produces daily children" { + var bufs: [3][3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&bufs[0], 2026, 4, 13, 100, 100, 0), + fixtureSnapshot(&bufs[1], 2026, 4, 14, 110, 110, 0), + fixtureSnapshot(&bufs[2], 2026, 4, 15, 120, 120, 0), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + + const parent: TierBucket = .{ + .tier = .weekly, + .bucket_start = Date.fromYmd(2026, 4, 13), + .bucket_end = Date.fromYmd(2026, 4, 19), + .representative_date = Date.fromYmd(2026, 4, 15), + .liquid = 120, + .illiquid = 120, + .net_worth = 120, + .imported_only = false, + .series_slice = series.points, + }; + + const children = try childBuckets(testing.allocator, parent); + defer testing.allocator.free(children); + + try testing.expectEqual(@as(usize, 3), children.len); + // Newest-first. + try testing.expect(children[0].bucket_start.eql(Date.fromYmd(2026, 4, 15))); + try testing.expect(children[2].bucket_start.eql(Date.fromYmd(2026, 4, 13))); + try testing.expect(children[0].tier == .daily); +} + +test "childBuckets: daily has no children" { + const parent: TierBucket = .{ + .tier = .daily, + .bucket_start = Date.fromYmd(2026, 5, 8), + .bucket_end = Date.fromYmd(2026, 5, 8), + .representative_date = Date.fromYmd(2026, 5, 8), + .liquid = 100, + .illiquid = 0, + .net_worth = 100, + .imported_only = false, + .series_slice = &.{}, + }; + const children = try childBuckets(testing.allocator, parent); + defer testing.allocator.free(children); + try testing.expectEqual(@as(usize, 0), children.len); +} + +test "finerTier transitions" { + try testing.expectEqual(Tier.quarterly, finerTier(.yearly).?); + try testing.expectEqual(Tier.monthly, finerTier(.quarterly).?); + try testing.expectEqual(Tier.weekly, finerTier(.monthly).?); + try testing.expectEqual(Tier.daily, finerTier(.weekly).?); + try testing.expectEqual(@as(?Tier, null), finerTier(.daily)); +} + +test "annualizedFromPct: 1-year window equals delta_pct" { + // Anchor 365 days back, 10% gain. CAGR = 10% / yr exactly + // (365 days / 365.25 = 0.9993 years; close enough that the + // result is essentially +10%). + const as_of = Date.fromYmd(2026, 5, 11); + const anchor = Date.fromYmd(2025, 5, 11); + const ann = annualizedFromPct(0.10, anchor, as_of).?; + try testing.expectApproxEqAbs(0.10, ann, 0.001); +} + +test "annualizedFromPct: 3-year doubling yields ~26% CAGR" { + // 100% gain over 3 years = 2^(1/3) - 1 ≈ 0.2599. + const as_of = Date.fromYmd(2026, 5, 11); + const anchor = Date.fromYmd(2023, 5, 11); + const ann = annualizedFromPct(1.0, anchor, as_of).?; + try testing.expectApproxEqAbs(0.2599, ann, 0.005); +} + +test "annualizedFromPct: 10-year +481.49% yields ~19.27% CAGR" { + // From the user's actual data: 10Y cumulative return. + const as_of = Date.fromYmd(2026, 5, 11); + const anchor = Date.fromYmd(2016, 5, 11); + const ann = annualizedFromPct(4.8149, anchor, as_of).?; + try testing.expectApproxEqAbs(0.1927, ann, 0.005); +} + +test "annualizedFromPct: null delta_pct → null" { + const as_of = Date.fromYmd(2026, 5, 11); + const anchor = Date.fromYmd(2025, 5, 11); + try testing.expectEqual(@as(?f64, null), annualizedFromPct(null, anchor, as_of)); +} + +test "annualizedFromPct: anchor in future → null" { + const as_of = Date.fromYmd(2026, 5, 11); + const future = Date.fromYmd(2027, 5, 11); + try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, future, as_of)); +} + +test "annualizedFromPct: same-day anchor → null (years <= 0)" { + const as_of = Date.fromYmd(2026, 5, 11); + try testing.expectEqual(@as(?f64, null), annualizedFromPct(0.10, as_of, as_of)); +} + +test "annualizedFromPct: equity-going-negative case → null" { + // delta_pct = -1.5 means we lost 150% — impossible from + // positive starting equity, but defensive. + const as_of = Date.fromYmd(2026, 5, 11); + const anchor = Date.fromYmd(2025, 5, 11); + try testing.expectEqual(@as(?f64, null), annualizedFromPct(-1.5, anchor, as_of)); +} + +test "computeWindowSet: populates annualized_pct on real-ish data" { + // 4 snapshots: 2014-07-03 ($1.28M), 2020-05-11 ($4.0M), + // 2025-05-11 ($7.4M), 2026-05-11 ($8.6M). + var bufs: [4][3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&bufs[0], 2014, 7, 3, 1_280_000, 1_280_000, 0), + fixtureSnapshot(&bufs[1], 2020, 5, 11, 4_000_000, 4_000_000, 0), + fixtureSnapshot(&bufs[2], 2025, 5, 11, 7_400_000, 7_400_000, 0), + fixtureSnapshot(&bufs[3], 2026, 5, 11, 8_600_000, 8_600_000, 0), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + + const ws = try computeWindowSet(testing.allocator, series.points, .liquid, Date.fromYmd(2026, 5, 11)); + defer ws.deinit(); + + // Find the 1Y row and the all-time row. + var found_1y = false; + var found_all = false; + for (ws.rows) |row| { + if (row.period) |p| { + if (p == .@"1Y") { + found_1y = true; + // 1Y: $7.4M → $8.6M = ~16.2% cumulative. + // Anchor at 2025-05-11, today 2026-05-11 = exactly 365 days. + const ann = row.annualized_pct.?; + try testing.expectApproxEqAbs(0.1622, ann, 0.005); + } + } else { + // All-time row. + found_all = true; + // ~11.85 years, +$1.28M → +$8.6M = ~572% cumulative + // → CAGR ~17.4% + const ann = row.annualized_pct.?; + try testing.expectApproxEqAbs(0.174, ann, 0.01); + } + } + try testing.expect(found_1y); + try testing.expect(found_all); +} + +test "computeWindowSet: missing anchor leaves annualized_pct null" { + // A series too short for a 5Y anchor: should produce null + // both for delta_pct and annualized_pct on that row. + var bufs: [2][3]snapshot.TotalRow = undefined; + const snaps = [_]snapshot.Snapshot{ + fixtureSnapshot(&bufs[0], 2025, 1, 1, 1000, 1000, 0), + fixtureSnapshot(&bufs[1], 2026, 5, 11, 2000, 2000, 0), + }; + const series = try buildSeries(testing.allocator, &snaps); + defer series.deinit(); + + const ws = try computeWindowSet(testing.allocator, series.points, .liquid, Date.fromYmd(2026, 5, 11)); + defer ws.deinit(); + + for (ws.rows) |row| { + if (row.period) |p| { + if (p == .@"5Y" or p == .@"10Y") { + try testing.expectEqual(@as(?f64, null), row.annualized_pct); + } + } + } +} diff --git a/src/commands/history.zig b/src/commands/history.zig index 120b2a7..b3a0abd 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -57,8 +57,14 @@ pub const PortfolioOpts = struct { /// Defaults to `.liquid` — matches the TUI history-tab default and /// is the most common reading ("how are my markets doing?"). metric: timeline.Metric = .liquid, - /// User-forced resolution. Null means "auto" (derive from span). + /// User-forced resolution. Null + `resolution_auto = false` means + /// "use the cascading default." Null + `resolution_auto = true` + /// means "use the legacy auto-pick (one of daily/weekly/monthly + /// based on span)." resolution: ?timeline.Resolution = null, + /// True iff the user passed `--resolution auto`. Distinguishes + /// "user explicitly asked for auto" from "user said nothing." + resolution_auto: bool = false, /// Max rows shown in the recent-snapshots table. Null means default (40). limit: ?usize = null, rebuild_rollup: bool = false, @@ -92,8 +98,10 @@ pub fn parsePortfolioOpts(as_of: zfin.Date, args: []const []const u8) Error!Port if (i >= args.len) return error.MissingFlagValue; if (std.mem.eql(u8, args[i], "auto")) { opts.resolution = null; + opts.resolution_auto = true; } else { opts.resolution = std.meta.stringToEnum(timeline.Resolution, args[i]) orelse return error.UnknownResolution; + opts.resolution_auto = false; } } else if (std.mem.eql(u8, a, "--limit")) { i += 1; @@ -230,7 +238,18 @@ fn runPortfolio( return; } - const resolution = opts.resolution orelse timeline.selectResolution(filtered); + // Resolve the effective resolution: + // - explicit `--resolution daily/weekly/monthly/cascading` → + // use as-is. + // - `--resolution auto` → pick one of daily/weekly/monthly + // based on the series span (legacy behavior). + // - omitted → `cascading` (the human-facing default). + const resolution: timeline.Resolution = if (opts.resolution) |r| + r + else if (opts.resolution_auto) + timeline.selectResolution(filtered) + else + .cascading; try renderPortfolio(allocator, out, color, filtered, opts.metric, resolution, opts.resolution, opts.limit orelse 40); } @@ -312,6 +331,15 @@ pub fn renderPortfolio( try renderBrailleChart(allocator, out, color, points, focus_metric); // ── Table ──────────────────────────────────────────────── + if (resolution == .cascading) { + try out.print("\n", .{}); + const ts = try timeline.aggregateCascading(allocator, points, as_of); + defer ts.deinit(); + try renderCascadingTable(allocator, out, color, ts, focus_metric, row_limit); + return; + } + + // Flat aggregation (daily/weekly/monthly/auto). // Aggregate first, then compute per-row deltas on the aggregated // series — this way row color matches the Δ column shown. const aggregated = try timeline.aggregatePoints(allocator, points, resolution); @@ -333,19 +361,20 @@ pub fn renderPortfolio( fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet) !void { if (ws.rows.len == 0) return; - // Header row: " Change Δ %" - // Widths pinned to view.windows_*_width constants (12 / 18 / 10). + // Header row: " Change Δ % % / yr" + // Widths pinned to view.windows_*_width constants (12 / 18 / 10 / 10). // Hard-coded here for format-string brevity; changes to those // constants must be mirrored in the literal widths below. try cli.setFg(out, color, cli.CLR_MUTED); - try out.print(" {s:<12} {s:>18} {s:>10}\n", .{ "Change", "Δ", "%" }); - try out.print(" {s:-<12} {s:->18} {s:->10}\n", .{ "", "", "" }); + try out.print(" {s:<12} {s:>18} {s:>10} {s:>10}\n", .{ "Change", "Δ", "%", "% / yr" }); + try out.print(" {s:-<12} {s:->18} {s:->10} {s:->10}\n", .{ "", "", "", "" }); try cli.reset(out, color); for (ws.rows) |row| { var dbuf: [32]u8 = undefined; var pbuf: [16]u8 = undefined; - const cells = view.buildWindowRowCells(row, &dbuf, &pbuf); + var abuf: [16]u8 = undefined; + const cells = view.buildWindowRowCells(row, &dbuf, &pbuf, &abuf); // Whole row colored by style intent. `muted` covers both // zero and missing-anchor rows — neither deserves a @@ -357,10 +386,11 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet) .normal => {}, } - try out.print(" {s:<12} {s:>18} {s:>10}", .{ + try out.print(" {s:<12} {s:>18} {s:>10} {s:>10}", .{ cells.label, cells.delta_str, cells.pct_str, + cells.ann_str, }); try cli.reset(out, color); try out.writeByte('\n'); @@ -376,18 +406,22 @@ fn renderBrailleChart( ) !void { if (points.len < 2) return; - // Synthesize candles from the focused metric's value. Same pattern - // the TUI history tab uses — keeps the chart primitive agnostic of - // portfolio-specific types. - const candles = try allocator.alloc(zfin.Candle, points.len); - defer allocator.free(candles); - for (points, 0..) |p, i| { + // Synthesize candles from the focused metric's value. For + // illiquid / net_worth, skip imported-only points so the + // line is visually absent in the imported-only range rather + // than hugging zero. + var candles_list: std.ArrayList(zfin.Candle) = .empty; + defer candles_list.deinit(allocator); + try candles_list.ensureTotalCapacity(allocator, points.len); + const skip_imported = (metric == .illiquid) or (metric == .net_worth); + for (points) |p| { + if (skip_imported and p.source == .imported) continue; const v = switch (metric) { .net_worth => p.net_worth, .liquid => p.liquid, .illiquid => p.illiquid, }; - candles[i] = .{ + try candles_list.append(allocator, .{ .date = p.as_of_date, .open = v, .high = v, @@ -395,8 +429,10 @@ fn renderBrailleChart( .close = v, .adj_close = v, .volume = 0, - }; + }); } + const candles = candles_list.items; + if (candles.len < 2) return; var chart = fmt.computeBrailleChart(allocator, candles, 60, 10, cli.CLR_POSITIVE, cli.CLR_NEGATIVE) catch return; defer chart.deinit(allocator); @@ -419,13 +455,13 @@ fn renderTable( try cli.setFg(out, color, cli.CLR_MUTED); // Column order: Liquid → Illiquid → Net Worth (components sum to total). - try out.print(" {s:>10} {s:>28} {s:>28} {s:>28}\n", .{ + try out.print(" {s:>10} {s:>31} {s:>31} {s:>31}\n", .{ "Date", "Liquid (Δ)", "Illiquid (Δ)", "Net Worth (Δ)", }); - try out.print(" {s:->10} {s:->28} {s:->28} {s:->28}\n", .{ "", "", "", "" }); + try out.print(" {s:->10} {s:->31} {s:->31} {s:->31}\n", .{ "", "", "", "" }); try cli.reset(out, color); // Newest-first iteration. `row_limit` caps how many rows we emit. @@ -482,6 +518,102 @@ fn writeTableRow( try out.writeByte('\n'); } +// ── Cascading-mode rendering ───────────────────────────────── + +/// Render the cascading recent-snapshots view. +/// +/// Each bucket renders as a row. The date column shows +/// granularity-appropriate labels ("W of YYYY-MM-DD", "Mar 2026", +/// "Q1 2025", "2024"). There are no tier-separator rows; the +/// hierarchy is implicit in the row labels. +/// +/// Imported-only buckets render `—` for illiquid and net_worth +/// since those metrics aren't recorded in `imported_values.srf`. +fn renderCascadingTable( + allocator: std.mem.Allocator, + out: *std.Io.Writer, + color: bool, + ts: timeline.TieredSeries, + focus_metric: timeline.Metric, + row_limit: usize, +) !void { + try cli.printBold(out, color, " Recent snapshots\n", .{}); + + if (ts.buckets.len == 0) { + try cli.setStyleIntent(out, color, .muted); + try out.writeAll(" No history data available.\n"); + try cli.reset(out, color); + return; + } + + // Header row. + try cli.setFg(out, color, cli.CLR_MUTED); + try out.print(" {s:>16} {s:>31} {s:>31} {s:>31}\n", .{ + "Date", + "Liquid (Δ)", + "Illiquid (Δ)", + "Net Worth (Δ)", + }); + try out.print(" {s:->16} {s:->31} {s:->31} {s:->31}\n", .{ "", "", "", "" }); + try cli.reset(out, color); + + // Pre-compute per-bucket Δ. Δ on bucket `i` is "current + // minus older neighbor" (`buckets[i].value - buckets[i+1].value`), + // matching the daily flat-table convention. + const deltas = try timeline.computeBucketDeltas(allocator, ts.buckets); + defer allocator.free(deltas); + + var emitted: usize = 0; + + for (ts.buckets, 0..) |b, idx| { + if (emitted >= row_limit) break; + const d = deltas[idx]; + + // Date label varies by tier. + var date_buf: [32]u8 = undefined; + const date_label = timeline.formatBucketLabel(&date_buf, b.tier, b.bucket_start); + + // Row color: same convention as flat table — focused-metric Δ. + const focus_delta_opt: ?f64 = switch (focus_metric) { + .liquid => d.delta_liquid, + .illiquid => d.delta_illiquid, + .net_worth => d.delta_net_worth, + }; + if (focus_delta_opt) |fd| { + if (fd == 0) { + try cli.setFg(out, color, cli.CLR_MUTED); + } else { + try cli.setGainLoss(out, color, fd); + } + } else { + try cli.setFg(out, color, cli.CLR_MUTED); + } + + var cbuf_l: [64]u8 = undefined; + var cbuf_i: [64]u8 = undefined; + var cbuf_n: [64]u8 = undefined; + + try out.print(" {s:>16} ", .{date_label}); + try out.writeAll(view.fmtValueDeltaCell(&cbuf_l, b.liquid, d.delta_liquid, view.table_cell_width)); + try out.writeAll(" "); + if (b.imported_only) { + // Render illiquid/net_worth as `—` to signal "no data." + try out.writeAll(fmt.centerDash(&cbuf_i, view.table_cell_width)); + try out.writeAll(" "); + try out.writeAll(fmt.centerDash(&cbuf_n, view.table_cell_width)); + } else { + try out.writeAll(view.fmtValueDeltaCell(&cbuf_i, b.illiquid, d.delta_illiquid, view.table_cell_width)); + try out.writeAll(" "); + try out.writeAll(view.fmtValueDeltaCell(&cbuf_n, b.net_worth, d.delta_net_worth, view.table_cell_width)); + } + try cli.reset(out, color); + try out.writeByte('\n'); + emitted += 1; + } + + try out.print("\n {d} buckets shown\n\n", .{emitted}); +} + // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; diff --git a/src/commands/milestones.zig b/src/commands/milestones.zig new file mode 100644 index 0000000..1d6cfeb --- /dev/null +++ b/src/commands/milestones.zig @@ -0,0 +1,407 @@ +//! `zfin milestones` — show portfolio threshold crossings. +//! +//! Given the merged history series (native `*-portfolio.srf` +//! snapshots take precedence over `imported_values.srf` on +//! overlapping dates), find the dates the portfolio first +//! reached each of a configured set of thresholds. +//! +//! Two threshold modes: +//! - `--step 1M` (or `1000000`, `500K`, etc.) — fixed dollar +//! multiples. +//! - `--step 2x` — geometric multiples of the starting value +//! ("doublings", "1.5x growth", etc.). +//! +//! Optional `--real` flag deflates the series to a reference +//! year (last full year in `shiller.annual_returns`) before +//! detecting crossings. +//! +//! No I/O beyond reading the data files; no network. + +const std = @import("std"); +const zfin = @import("../root.zig"); +const cli = @import("common.zig"); +const fmt = @import("../format.zig"); +const history = @import("../history.zig"); +const Date = @import("../models/date.zig").Date; +const milestones = @import("../analytics/milestones.zig"); +const shiller = @import("../data/shiller.zig"); + +pub const RunError = error{ + UnexpectedArg, + MissingStep, + InvalidStep, + NoData, + OutOfMemory, + WriteFailed, +} || std.Io.Reader.Error || std.Io.Writer.Error || std.fs.File.OpenError || + std.posix.RealPathError; + +const usage_text = + \\Usage: zfin milestones --step [--real] + \\ + \\ --step Threshold step. Examples: + \\ 1M / 1m / 1500000 / 1.5M (absolute dollar) + \\ 500K / 500k (absolute thousands) + \\ 2x / 2X / 1.5x (relative multiplier) + \\ Rejects %, <=0, <=1.0x, NaN, Inf. + \\ --real Deflate the series to the last full Shiller year + \\ before detecting crossings (CPI-adjusted dollars). + \\ Default is nominal. + \\ -h, --help Show this help. + \\ + \\Note: crossing dates are "first observed at," bounded by the + \\source series cadence (typically weekly). + \\ +; + +pub fn run( + io: std.Io, + allocator: std.mem.Allocator, + svc: *zfin.DataService, + portfolio_path: []const u8, + args: []const []const u8, + today: Date, + color: bool, + out: *std.Io.Writer, +) !void { + _ = svc; // milestones reads local files only + _ = today; // current behavior: detection bounded by series, no "now" + var step_str: ?[]const u8 = null; + var want_real = false; + + var i: usize = 0; + while (i < args.len) : (i += 1) { + const a = args[i]; + if (std.mem.eql(u8, a, "--step")) { + i += 1; + if (i >= args.len) { + try cli.stderrPrint(io, "Error: --step requires an argument\n"); + return error.MissingStep; + } + step_str = args[i]; + } else if (std.mem.eql(u8, a, "--real")) { + want_real = true; + } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) { + try out.writeAll(usage_text); + return; + } else { + try cli.stderrPrint(io, "Error: unknown argument to 'milestones': "); + try cli.stderrPrint(io, a); + try cli.stderrPrint(io, "\n"); + return error.UnexpectedArg; + } + } + + const step_input = step_str orelse { + try cli.stderrPrint(io, "Error: --step is required\n"); + try cli.stderrPrint(io, usage_text); + return error.MissingStep; + }; + + const step = milestones.parseStep(step_input) catch |err| { + var buf: [256]u8 = undefined; + const msg = std.fmt.bufPrint( + &buf, + "Error: cannot parse --step '{s}': {s}\n", + .{ step_input, @errorName(err) }, + ) catch "Error: invalid --step\n"; + try cli.stderrPrint(io, msg); + return error.InvalidStep; + }; + + // Load merged series. + var series_owned = try loadMergedSeries(io, allocator, portfolio_path); + defer series_owned.deinit(allocator); + + if (series_owned.points.len == 0) { + try cli.stderrPrint(io, "Error: no history data found. Did you import imported_values.srf?\n"); + return error.NoData; + } + + // Inflation adjustment: deflate the series to the reference year. + const reference_year: u16 = shiller.last_year; + const cpi_view = try buildCpiView(allocator); + defer allocator.free(cpi_view); + + const series = if (want_real) blk: { + const deflated = try allocator.alloc(milestones.Point, series_owned.points.len); + for (series_owned.points, 0..) |p, idx| { + const yr: u16 = @intCast(p.date.year()); + deflated[idx] = .{ + .date = p.date, + .value = milestones.deflate(p.value, yr, reference_year, cpi_view), + }; + } + break :blk deflated; + } else series_owned.points; + defer if (want_real) allocator.free(series); + + // Detect crossings. + const crossings = try milestones.detectCrossings(allocator, series, step); + defer allocator.free(crossings); + + // Render. + try renderHeader(out, color, step, want_real, reference_year, series); + if (crossings.len == 0) { + try renderNoCrossings(out, color, series); + return; + } + try renderTable(out, color, step, crossings); +} + +// ── Series loading ─────────────────────────────────────────── + +/// A lightweight owned merged series: imported values overlaid +/// with native snapshots (snapshots win on overlap), sorted +/// ascending by date, deduped. +const MergedSeries = struct { + points: []milestones.Point, + + fn deinit(self: *MergedSeries, allocator: std.mem.Allocator) void { + allocator.free(self.points); + self.points = &.{}; + } +}; + +/// Thin wrapper over `history.loadTimeline` that projects the +/// shared `TimelineSeries` (rich `TimelinePoint` records with +/// liquid/illiquid/breakdowns/source) into the lightweight +/// `(date, liquid)` shape that milestone-detection consumes. +/// +/// The merge logic — including snapshot-wins-on-overlap, sort +/// order, and `imported_values.srf` discovery — lives in +/// `history.loadTimeline` and `timeline.buildMergedSeries`. +/// Keeping milestones routed through that single source of +/// truth means future improvements (e.g. honoring more snapshot +/// metadata) propagate automatically. +fn loadMergedSeries( + io: std.Io, + allocator: std.mem.Allocator, + portfolio_path: []const u8, +) !MergedSeries { + var tl = try history.loadTimeline(io, allocator, portfolio_path); + defer tl.deinit(); + + const points = try allocator.alloc(milestones.Point, tl.series.points.len); + for (tl.series.points, 0..) |p, i| { + points[i] = .{ .date = p.as_of_date, .value = p.liquid }; + } + return .{ .points = points }; +} + +// ── CPI view builder ───────────────────────────────────────── + +fn buildCpiView( + allocator: std.mem.Allocator, +) ![]milestones.YearCpi { + const data = shiller.annual_returns; + const view = try allocator.alloc(milestones.YearCpi, data.len); + for (data, 0..) |yr, i| { + view[i] = .{ .year = yr.year, .cpi = yr.cpi_inflation }; + } + return view; +} + +// ── Rendering ──────────────────────────────────────────────── + +fn renderHeader( + out: *std.Io.Writer, + color: bool, + step: milestones.Step, + want_real: bool, + reference_year: u16, + series: []const milestones.Point, +) !void { + var buf: [64]u8 = undefined; + try cli.setBold(out, color); + switch (step) { + .absolute => |s| { + const money = fmt.fmtMoneyAbs(&buf, s); + if (want_real) { + try out.print( + "Milestones — step {s} (real, reference year: {d})\n", + .{ money, reference_year }, + ); + } else { + try out.print( + "Milestones — step {s} (nominal)\n", + .{money}, + ); + } + }, + .relative => |f| { + const start = series[0].value; + const start_money = fmt.fmtMoneyAbs(&buf, start); + var date_buf: [10]u8 = undefined; + const start_date_str = series[0].date.format(&date_buf); + const real_str = if (want_real) " (real)" else ""; + try out.print( + "Milestones — step {d}x from {s} ({s}){s}\n", + .{ f, start_money, start_date_str, real_str }, + ); + }, + } + try cli.reset(out, color); + try out.writeAll("\n"); +} + +fn renderNoCrossings( + out: *std.Io.Writer, + color: bool, + series: []const milestones.Point, +) !void { + var max_v: f64 = series[0].value; + for (series) |p| { + if (p.value > max_v) max_v = p.value; + } + const start_v = series[0].value; + var buf_max: [32]u8 = undefined; + var buf_start: [32]u8 = undefined; + const max_str = fmt.fmtMoneyAbs(&buf_max, max_v); + const start_str = fmt.fmtMoneyAbs(&buf_start, start_v); + try cli.setStyleIntent(out, color, .muted); + try out.print( + " No milestones reached. Series max: {s} (start: {s}).\n", + .{ max_str, start_str }, + ); + try cli.reset(out, color); +} + +fn renderTable( + out: *std.Io.Writer, + color: bool, + step: milestones.Step, + crossings: []const milestones.Crossing, +) !void { + const is_relative = step == .relative; + + // Header row. + try cli.setBold(out, color); + if (is_relative) { + try out.writeAll(" Multiple Threshold Date Crossed Days Since Prev Days Since First\n"); + try out.writeAll(" ──────── ────────── ───────────── ─────────────── ────────────────\n"); + } else { + try out.writeAll(" Milestone Date Crossed Days Since Prev Days Since First\n"); + try out.writeAll(" ───────── ───────────── ─────────────── ────────────────\n"); + } + try cli.reset(out, color); + + var has_start_row = false; + for (crossings) |c| { + var date_buf: [10]u8 = undefined; + const date_str = c.date.format(&date_buf); + var money_buf: [32]u8 = undefined; + const money_str = fmt.fmtMoneyAbs(&money_buf, c.threshold); + + // The "days since prev" cell holds either "N days" (ASCII) + // or the em-dash sentinel "—" (3 bytes / 1 display col). + // Zig's `{s: 0) c.threshold / start_threshold else 0; + try out.print( + " {d: <8.4}x {s: <16} {s: <16}{s} {d} days{s}\n", + .{ multiple, money_str, date_str, ds_prev_cell, c.days_since_first, star }, + ); + } else { + try out.print( + " {s: <16} {s: <16}{s} {d} days{s}\n", + .{ money_str, date_str, ds_prev_cell, c.days_since_first, star }, + ); + } + } + + if (has_start_row) { + try out.writeAll("\n"); + try cli.setStyleIntent(out, color, .muted); + try out.writeAll(" * starting value, not a true crossing.\n"); + try cli.reset(out, color); + } +} + +// ── Tests ──────────────────────────────────────────────────── + +test "loadMergedSeries: empty when no history" { + const io = std.testing.io; + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf); + const dir_path = path_buf[0..dir_len]; + + // A portfolio path that has no sibling history dir. + const fake_pf = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "portfolio.srf" }); + defer std.testing.allocator.free(fake_pf); + + var s = try loadMergedSeries(io, std.testing.allocator, fake_pf); + defer s.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 0), s.points.len); +} + +test "loadMergedSeries: imported values only" { + const io = std.testing.io; + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf); + const dir_path = path_buf[0..dir_len]; + + // Create history/ dir and imported_values.srf. + const hist_dir = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "history" }); + defer std.testing.allocator.free(hist_dir); + try std.Io.Dir.cwd().createDirPath(io, hist_dir); + + const iv_path = try std.fs.path.join(std.testing.allocator, &.{ hist_dir, "imported_values.srf" }); + defer std.testing.allocator.free(iv_path); + + const iv_data = + \\#!srfv1 + \\date::2014-07-03,liquid:num:1280000 + \\date::2015-01-09,liquid:num:1500000 + \\date::2020-06-01,liquid:num:3000000 + \\ + ; + { + var f = try std.Io.Dir.cwd().createFile(io, iv_path, .{}); + try f.writeStreamingAll(io, iv_data); + f.close(io); + } + + const fake_pf = try std.fs.path.join(std.testing.allocator, &.{ dir_path, "portfolio.srf" }); + defer std.testing.allocator.free(fake_pf); + + var s = try loadMergedSeries(io, std.testing.allocator, fake_pf); + defer s.deinit(std.testing.allocator); + try std.testing.expectEqual(@as(usize, 3), s.points.len); + try std.testing.expectEqual(Date.fromYmd(2014, 7, 3), s.points[0].date); + try std.testing.expectEqual(@as(f64, 1_280_000), s.points[0].value); + try std.testing.expectEqual(Date.fromYmd(2020, 6, 1), s.points[2].date); +} diff --git a/src/data/imported_values.zig b/src/data/imported_values.zig new file mode 100644 index 0000000..d182388 --- /dev/null +++ b/src/data/imported_values.zig @@ -0,0 +1,389 @@ +//! Imported portfolio history values from a manually-curated spreadsheet. +//! +//! Pre-dates `~/finance/history/` snapshot capture. Contains weekly +//! `(date, liquid, expected_return, projected_retirement)` tuples +//! transcribed from a third-party spreadsheet (the "K+E Funds" +//! workbook for the primary portfolio, plus optional sibling +//! spreadsheets for other portfolios in the same template). +//! +//! ## Lifecycle +//! +//! 1. The user exports the spreadsheet to CSV via `ssconvert -S`. +//! 2. `tools/import_values.zig` (a one-shot Zig program) consumes +//! the CSV and emits an `imported_values.srf` next to the +//! repo's `~/finance/history/` snapshot directory. +//! 3. This module loads that SRF for downstream consumers +//! (`zfin milestones`, projection overlay, forecast-vs-actual). +//! +//! The SRF is a derived artifact, not a source of truth. Hand +//! editing it is explicitly disallowed — the spreadsheet is the +//! source, and the importer regenerates the SRF wholesale. +//! +//! ## Field semantics +//! +//! - `date` — week-ending date (typically a Friday). +//! - `liquid` — total liquid net worth in USD on that date. +//! Always present. +//! - `expected_return` — the spreadsheet's +//! `min(1y,3y,5y,10y)`-weighted return assumption used to +//! derive `projected_retirement`. Optional. Decimal +//! (e.g., `0.1255` = 12.55%/yr). +//! - `projected_retirement` — the spreadsheet's predicted +//! retirement-readiness date as of `date`. Optional. Tagged +//! union: a future date, the `reached` sentinel meaning +//! "model said you're already there", or absent. + +const std = @import("std"); +const srf = @import("srf"); +const Date = @import("../models/date.zig").Date; + +// ── Types ──────────────────────────────────────────────────── + +/// Projection of "when can the user retire," as captured at a +/// historical observation date. +/// +/// `reached` is a sentinel meaning the model said "the user is +/// already at retirement readiness." It can flip back to a date +/// in a later week if a market correction pushes the projection +/// out (rare but observed in the Jan 2026 K+E Funds data: 81 of +/// the most-recent rows are `reached`, but historically the value +/// has wobbled). +pub const ProjectedRetirement = union(enum) { + reached, + date: Date, + + /// SRF parser hook. Accepts `reached` (case-insensitive) or + /// `YYYY-MM-DD`. Any other shape is rejected. + pub fn srfParse(str: []const u8) !ProjectedRetirement { + if (std.ascii.eqlIgnoreCase(str, "reached")) return .reached; + const d = Date.parse(str) catch return error.InvalidProjectedRetirement; + return .{ .date = d }; + } + + /// SRF serializer hook. Emits `reached` or `YYYY-MM-DD`. + pub fn srfFormat( + self: ProjectedRetirement, + allocator: std.mem.Allocator, + comptime field_name: []const u8, + ) !srf.Value { + _ = field_name; + return switch (self) { + .reached => .{ .string = try allocator.dupe(u8, "reached") }, + .date => |d| blk: { + const buf = try allocator.alloc(u8, 10); + _ = d.format(buf[0..10]); + break :blk .{ .string = buf }; + }, + }; + } + + pub fn eql(a: ProjectedRetirement, b: ProjectedRetirement) bool { + return switch (a) { + .reached => b == .reached, + .date => |ad| switch (b) { + .reached => false, + .date => |bd| ad.eql(bd), + }, + }; + } +}; + +/// One record from `imported_values.srf`. All fields besides +/// `date` and `liquid` are optional (the importer omits them +/// when the source spreadsheet row had no value). +pub const HistoryPoint = struct { + date: Date, + liquid: f64, + expected_return: ?f64 = null, + projected_retirement: ?ProjectedRetirement = null, +}; + +/// Owned, oldest-first slice of `HistoryPoint` records loaded +/// from `imported_values.srf`. The slice's lifetime is tied to +/// `allocator`; call `deinit` when done. +pub const ImportedValues = struct { + points: []HistoryPoint, + allocator: std.mem.Allocator, + + /// Free the underlying slice. Records contain no allocated + /// substrings (`Date` and `f64` are values; the union is a + /// value). + pub fn deinit(self: *ImportedValues) void { + self.allocator.free(self.points); + self.points = &.{}; + } +}; + +// ── Loader ─────────────────────────────────────────────────── + +pub const LoadError = error{ + /// SRF parse failed (malformed file, missing required field, + /// or otherwise unreadable). + InvalidSrf, + /// Two records share the same `date` field. The importer + /// guarantees this can't happen for a valid output, so this + /// indicates corruption or hand-editing. + DuplicateDate, + /// Records are not strictly date-ascending. The importer + /// guarantees ascending order, so this indicates corruption + /// or hand-editing. + NotSorted, +} || std.mem.Allocator.Error; + +/// Load `imported_values.srf` from the given path. +/// +/// Returns an `ImportedValues` with an empty slice if the file +/// does not exist (treated as "no historical data available", +/// not an error). All other failures return a non-null error. +/// +/// Caller owns the returned `ImportedValues`; call `deinit`. +pub fn loadImportedValues( + io: std.Io, + allocator: std.mem.Allocator, + path: []const u8, +) !ImportedValues { + const bytes = std.Io.Dir.cwd().readFileAlloc( + io, + path, + allocator, + .limited(50 * 1024 * 1024), + ) catch |err| switch (err) { + error.FileNotFound => return ImportedValues{ + .points = &.{}, + .allocator = allocator, + }, + else => return err, + }; + defer allocator.free(bytes); + + return parseImportedValues(allocator, bytes); +} + +/// Parse `imported_values.srf` bytes. Lower-level entry point — +/// `loadImportedValues` is the typical call site. +/// +/// Validates: ascending-date order and no duplicate dates. +/// String fields on each record are owned by the returned slice +/// (no borrows from `bytes` — they're parsed into value-typed +/// fields only). +pub fn parseImportedValues( + allocator: std.mem.Allocator, + bytes: []const u8, +) !ImportedValues { + var reader = std.Io.Reader.fixed(bytes); + var it = srf.iterator(&reader, allocator, .{ .alloc_strings = false }) catch return error.InvalidSrf; + defer it.deinit(); + + var points: std.ArrayList(HistoryPoint) = .empty; + errdefer points.deinit(allocator); + + while (it.next() catch return error.InvalidSrf) |fields| { + const point = fields.to(HistoryPoint) catch return error.InvalidSrf; + try points.append(allocator, point); + } + + // Validate ordering and uniqueness in one pass. + if (points.items.len > 1) { + var prev = points.items[0].date; + for (points.items[1..]) |p| { + if (p.date.eql(prev)) return error.DuplicateDate; + if (p.date.lessThan(prev)) return error.NotSorted; + prev = p.date; + } + } + + const owned = try points.toOwnedSlice(allocator); + return ImportedValues{ .points = owned, .allocator = allocator }; +} + +// ── Tests ──────────────────────────────────────────────────── + +test "ProjectedRetirement.srfParse: reached" { + const v = try ProjectedRetirement.srfParse("reached"); + try std.testing.expect(v == .reached); + + // Case-insensitive. + const v2 = try ProjectedRetirement.srfParse("REACHED"); + try std.testing.expect(v2 == .reached); +} + +test "ProjectedRetirement.srfParse: date" { + const v = try ProjectedRetirement.srfParse("2030-01-15"); + try std.testing.expect(v == .date); + try std.testing.expectEqual(@as(i16, 2030), v.date.year()); + try std.testing.expectEqual(@as(u8, 1), v.date.month()); + try std.testing.expectEqual(@as(u8, 15), v.date.day()); +} + +test "ProjectedRetirement.srfParse: invalid" { + try std.testing.expectError(error.InvalidProjectedRetirement, ProjectedRetirement.srfParse("not a date")); + try std.testing.expectError(error.InvalidProjectedRetirement, ProjectedRetirement.srfParse("")); + try std.testing.expectError(error.InvalidProjectedRetirement, ProjectedRetirement.srfParse("2030/01/15")); +} + +test "ProjectedRetirement.eql" { + const r: ProjectedRetirement = .reached; + const r2: ProjectedRetirement = .reached; + const d1 = ProjectedRetirement{ .date = Date.fromYmd(2030, 1, 15) }; + const d2 = ProjectedRetirement{ .date = Date.fromYmd(2030, 1, 15) }; + const d3 = ProjectedRetirement{ .date = Date.fromYmd(2030, 1, 16) }; + + try std.testing.expect(r.eql(r2)); + try std.testing.expect(d1.eql(d2)); + try std.testing.expect(!d1.eql(d3)); + try std.testing.expect(!r.eql(d1)); + try std.testing.expect(!d1.eql(r)); +} + +test "parseImportedValues: minimal valid file" { + const data = + \\#!srfv1 + \\date::2014-07-03,liquid:num:1280036.42,expected_return:num:0.1223,projected_retirement::2023-09-27 + \\date::2014-07-10,liquid:num:1272951.94,expected_return:num:0.1206,projected_retirement::2023-11-24 + \\ + ; + var iv = try parseImportedValues(std.testing.allocator, data); + defer iv.deinit(); + + try std.testing.expectEqual(@as(usize, 2), iv.points.len); + try std.testing.expectEqual(Date.fromYmd(2014, 7, 3), iv.points[0].date); + try std.testing.expectEqual(@as(f64, 1280036.42), iv.points[0].liquid); + try std.testing.expectEqual(@as(?f64, 0.1223), iv.points[0].expected_return); + try std.testing.expect(iv.points[0].projected_retirement != null); + try std.testing.expect(iv.points[0].projected_retirement.? == .date); + try std.testing.expectEqual( + Date.fromYmd(2023, 9, 27), + iv.points[0].projected_retirement.?.date, + ); +} + +test "parseImportedValues: reached sentinel" { + const data = + \\#!srfv1 + \\date::2026-01-09,liquid:num:7912778.43,expected_return:num:0.1255,projected_retirement::reached + \\ + ; + var iv = try parseImportedValues(std.testing.allocator, data); + defer iv.deinit(); + + try std.testing.expectEqual(@as(usize, 1), iv.points.len); + try std.testing.expect(iv.points[0].projected_retirement != null); + try std.testing.expect(iv.points[0].projected_retirement.? == .reached); +} + +test "parseImportedValues: missing optional fields" { + const data = + \\#!srfv1 + \\date::2015-01-01,liquid:num:1500000.00 + \\ + ; + var iv = try parseImportedValues(std.testing.allocator, data); + defer iv.deinit(); + + try std.testing.expectEqual(@as(usize, 1), iv.points.len); + try std.testing.expectEqual(@as(?f64, null), iv.points[0].expected_return); + try std.testing.expectEqual(@as(?ProjectedRetirement, null), iv.points[0].projected_retirement); +} + +test "parseImportedValues: empty file (header only)" { + const data = "#!srfv1\n"; + var iv = try parseImportedValues(std.testing.allocator, data); + defer iv.deinit(); + + try std.testing.expectEqual(@as(usize, 0), iv.points.len); +} + +test "parseImportedValues: rejects duplicate dates" { + const data = + \\#!srfv1 + \\date::2015-01-01,liquid:num:1500000.00 + \\date::2015-01-01,liquid:num:1500001.00 + \\ + ; + try std.testing.expectError( + error.DuplicateDate, + parseImportedValues(std.testing.allocator, data), + ); +} + +test "parseImportedValues: rejects non-monotonic dates" { + const data = + \\#!srfv1 + \\date::2015-01-08,liquid:num:1500000.00 + \\date::2015-01-01,liquid:num:1490000.00 + \\ + ; + try std.testing.expectError( + error.NotSorted, + parseImportedValues(std.testing.allocator, data), + ); +} + +test "loadImportedValues: missing file returns empty" { + var iv = try loadImportedValues( + std.testing.io, + std.testing.allocator, + "/nonexistent/path/imported_values.srf", + ); + defer iv.deinit(); + try std.testing.expectEqual(@as(usize, 0), iv.points.len); +} + +test "loadImportedValues: round-trip via filesystem" { + const io = std.testing.io; + const data = + \\#!srfv1 + \\date::2014-07-03,liquid:num:1280036.42,expected_return:num:0.1223,projected_retirement::2023-09-27 + \\date::2014-07-10,liquid:num:1272951.94,projected_retirement::reached + \\ + ; + + var tmp_dir = std.testing.tmpDir(.{}); + defer tmp_dir.cleanup(); + + var path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_len = try tmp_dir.dir.realPathFile(io, ".", &path_buf); + const dir_path = path_buf[0..dir_len]; + const file_path = try std.fs.path.join( + std.testing.allocator, + &.{ dir_path, "imported_values.srf" }, + ); + defer std.testing.allocator.free(file_path); + + { + var f = try std.Io.Dir.cwd().createFile(io, file_path, .{}); + try f.writeStreamingAll(io, data); + f.close(io); + } + defer std.Io.Dir.cwd().deleteFile(io, file_path) catch {}; + + var iv = try loadImportedValues(io, std.testing.allocator, file_path); + defer iv.deinit(); + + try std.testing.expectEqual(@as(usize, 2), iv.points.len); + try std.testing.expectEqual(Date.fromYmd(2014, 7, 3), iv.points[0].date); + try std.testing.expectEqual(@as(f64, 1280036.42), iv.points[0].liquid); + try std.testing.expect(iv.points[1].projected_retirement.? == .reached); +} + +test "parseImportedValues: comments and blank lines tolerated" { + // Mirrors what tools/import_values.zig actually emits. + const data = + \\#!srfv1 + \\# Manually-imported weekly portfolio history. + \\# + \\# Re-generated wholesale; do not hand-edit. + \\ + \\date::2014-07-03,liquid:num:1280036.42,expected_return:num:0.1223,projected_retirement::2023-09-27 + \\date::2014-07-10,liquid:num:1272951.94 + \\date::2014-07-18,liquid:num:1274083.25,projected_retirement::reached + \\ + ; + var iv = try parseImportedValues(std.testing.allocator, data); + defer iv.deinit(); + + try std.testing.expectEqual(@as(usize, 3), iv.points.len); + try std.testing.expectEqual(@as(f64, 1280036.42), iv.points[0].liquid); + try std.testing.expectEqual(@as(?f64, null), iv.points[1].expected_return); + try std.testing.expect(iv.points[2].projected_retirement.? == .reached); +} diff --git a/src/format.zig b/src/format.zig index 686a7ac..9cd2e3b 100644 --- a/src/format.zig +++ b/src/format.zig @@ -277,6 +277,91 @@ pub fn fmtLargeNum(val: f64) [15]u8 { return result; } +// ── Display-width helpers ──────────────────────────────────── + +/// Count the number of terminal display columns occupied by a +/// UTF-8 string. ASCII bytes count as 1 column; multibyte UTF-8 +/// sequences (em-dash `—`, chevrons `▶`/`▼`, etc.) also count +/// as 1 column. Continuation bytes count as 0. +/// +/// This is a pragmatic helper for table-layout code, not a +/// full Unicode width database — it doesn't attempt to handle +/// East-Asian wide chars, combining marks, or zero-width +/// joiners. The strings that flow through table cells in this +/// codebase are short and use only single-column glyphs. +pub fn displayCols(s: []const u8) usize { + var cols: usize = 0; + var i: usize = 0; + while (i < s.len) { + const b = s[i]; + if (b < 0x80) { + cols += 1; + i += 1; + } else if (b < 0xC0) { + // Continuation byte (shouldn't lead a sequence; skip) + i += 1; + } else if (b < 0xE0) { + cols += 1; + i += 2; + } else if (b < 0xF0) { + cols += 1; + i += 3; + } else { + cols += 1; + i += 4; + } + } + return cols; +} + +/// Right-pad `content` to `target_cols` *display columns* by +/// appending spaces in-place. `content` must already live at +/// the start of `buf`; the function appends spaces directly +/// after it and returns the padded slice. If `content` is +/// already at or beyond `target_cols`, it's returned unchanged. +/// If `buf` is too small for the padded result, returns the +/// original content unmodified. +/// +/// Use when you've written a cell into a stack buffer and need +/// to pad it to a fixed-column table layout. Display-column +/// aware (so multibyte content like `—` doesn't get under-padded +/// the way Zig's byte-padding `{s: = target_cols) return content; + const pad = target_cols - have; + if (content.len + pad > buf.len) return content; + @memset(buf[content.len .. content.len + pad], ' '); + return buf[0 .. content.len + pad]; +} + +/// Render an em-dash (`—`) horizontally centered inside a cell +/// of `width` display columns, padded with spaces on both +/// sides. Used for table cells where the value is unavailable +/// (e.g. illiquid totals on imported-only history rows). +/// +/// Writes into `buf` and returns a slice of it. `buf` should be +/// at least `width + 2` bytes — the em-dash itself is 3 bytes / +/// 1 column, so the returned byte length is `width + 2` (one +/// 3-byte multibyte sequence in a width-col cell). +pub fn centerDash(buf: []u8, width: usize) []const u8 { + const dash = "—"; + const pad = (width -| 1) / 2; + var pos: usize = 0; + // Left padding (ASCII spaces — 1 byte = 1 col). + while (pos < pad and pos < buf.len) : (pos += 1) buf[pos] = ' '; + // Dash glyph (3 bytes, 1 col). + if (pos + dash.len <= buf.len) { + @memcpy(buf[pos .. pos + dash.len], dash); + pos += dash.len; + } + // Trailing padding: keep filling spaces until total *display + // columns* hits `width`. We can't reuse the `pos < width` + // shortcut from the left-pad loop because `pos` is now in + // bytes (post-dash) while `width` is in columns. + return padRightToCols(buf, buf[0..pos], width); +} + // ── Date / financial helpers ───────────────────────────────── /// Get today's date from the system clock. @@ -734,10 +819,8 @@ pub const BrailleChart = struct { /// The year context is already visible in the surrounding CLI/TUI interface. /// Returns the number of bytes written. pub fn fmtShortDate(date: Date, buf: *[7]u8) []const u8 { - const months = [_][]const u8{ "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" }; - const m = date.month(); + const mon = Date.monthShort(date.month()); const d = date.day(); - const mon = if (m >= 1 and m <= 12) months[m - 1] else "???"; buf[0] = mon[0]; buf[1] = mon[1]; buf[2] = mon[2]; @@ -751,6 +834,45 @@ pub const BrailleChart = struct { return buf[0..6]; } + /// Format a date for the chart's x-axis at a granularity + /// appropriate for that individual date's recency. Two tiers, + /// per-date (driven by how far back the date is from the + /// chart's `end_date` reference, not by the chart's overall + /// span): + /// - within 720 days of `end_date` → "DD MMM" (e.g., "08 May") + /// - older than 720 days → "MMM YYYY" (e.g., "Jul 2014") + /// + /// On a 12-year chart, this typically yields a long-format + /// start label (`Jul 2014`) paired with a short-format end + /// label (`08 May`) — the start is far enough back that + /// year context is what matters; the end is recent enough + /// that day-of-month resolution is useful. + /// + /// The day-first ordering for the short form is intentional: + /// when a chart pairs `"08 May"` with `"Jul 2014"`, the first + /// character of each label cleanly disambiguates the format + /// at a glance — digit-first is a recent date, letter-first is + /// a distant date. Saves the eye from re-parsing every label. + /// + /// `buf` must be at least 8 bytes; the returned slice borrows + /// from it. + pub fn fmtAxisDate(self: *const BrailleChart, date: Date, buf: *[8]u8) []const u8 { + const age_days = self.end_date.days - date.days; + const mon = Date.monthShort(date.month()); + + if (age_days <= 720) { + // "DD MMM" — day-first so the leading character is a + // digit (visually distinct from the letter-first + // long form below). + return std.fmt.bufPrint(buf, "{d:0>2} {s}", .{ date.day(), mon }) catch buf[0..0]; + } + // "MMM YYYY" — for dates more than ~2 years before + // `end_date`. Day-of-month resolution stops being useful + // at this scale; full 4-digit year keeps the label + // unambiguous regardless of how far back the chart goes. + return std.fmt.bufPrint(buf, "{s} {d:0>4}", .{ mon, @as(u16, @intCast(date.year())) }) catch buf[0..0]; + } + pub fn deinit(self: *BrailleChart, alloc: std.mem.Allocator) void { alloc.free(self.patterns); alloc.free(self.col_colors); @@ -906,10 +1028,10 @@ pub fn writeBrailleAnsi( // Date axis below chart if (!skip_date_axis) { - var start_buf: [7]u8 = undefined; - var end_buf: [7]u8 = undefined; - const start_label = BrailleChart.fmtShortDate(chart.start_date, &start_buf); - const end_label = BrailleChart.fmtShortDate(chart.end_date, &end_buf); + var start_buf: [8]u8 = undefined; + var end_buf: [8]u8 = undefined; + const start_label = chart.fmtAxisDate(chart.start_date, &start_buf); + const end_label = chart.fmtAxisDate(chart.end_date, &end_buf); if (use_color) try out.print("\x1b[38;2;{d};{d};{d}m", .{ muted_color[0], muted_color[1], muted_color[2] }); try out.writeAll(" "); // match leading indent @@ -1232,7 +1354,7 @@ test "brailleGlyph" { try std.testing.expectEqual(@as(u8, 0xBF), full[2]); } -test "BrailleChart.fmtShortDate" { +test "fmtShortDate" { var buf: [7]u8 = undefined; const jan15 = BrailleChart.fmtShortDate(Date.fromYmd(2024, 1, 15), &buf); try std.testing.expectEqualStrings("Jan 15", jan15); @@ -1441,3 +1563,169 @@ test "fmtTimeAgo: days" { // 7d try std.testing.expectEqualStrings("7d ago", fmtTimeAgo(&buf, 1_700_000_000, 1_700_000_000 + 7 * 86_400)); } + +test "fmtAxisDate: span <=720d produces DD MMM" { + var br: BrailleChart = undefined; + br.start_date = Date.fromYmd(2026, 1, 1); + br.end_date = Date.fromYmd(2026, 5, 11); + var buf: [8]u8 = undefined; + const lbl = br.fmtAxisDate(Date.fromYmd(2026, 4, 27), &buf); + try std.testing.expectEqualStrings("27 Apr", lbl); +} + +test "fmtAxisDate: ~2y span (around the threshold) produces DD MMM" { + // 700 days from 2024-01-01 — still inside the threshold. + var br: BrailleChart = undefined; + br.start_date = Date.fromYmd(2024, 1, 1); + br.end_date = Date.fromYmd(2025, 12, 1); // 700 days + var buf: [8]u8 = undefined; + const lbl = br.fmtAxisDate(Date.fromYmd(2024, 1, 1), &buf); + try std.testing.expectEqualStrings("01 Jan", lbl); +} + +test "fmtAxisDate: long-history chart shows MMM YYYY for old start, DD MMM for recent end" { + // 12-year chart: start is way more than 720 days from end, + // so the start gets MMM YYYY. End is `end_date` itself + // (age 0), so it gets DD MMM. + var br: BrailleChart = undefined; + br.start_date = Date.fromYmd(2014, 7, 3); + br.end_date = Date.fromYmd(2026, 5, 11); + var buf: [8]u8 = undefined; + const start_lbl = br.fmtAxisDate(br.start_date, &buf); + try std.testing.expectEqualStrings("Jul 2014", start_lbl); + var buf2: [8]u8 = undefined; + const end_lbl = br.fmtAxisDate(br.end_date, &buf2); + try std.testing.expectEqualStrings("11 May", end_lbl); +} + +test "fmtAxisDate: boundary at exactly 720 days uses DD MMM" { + var br: BrailleChart = undefined; + br.start_date = Date.fromYmd(2025, 1, 1); + // 720 days later = 2026-12-22. + br.end_date = Date.fromYmd(2026, 12, 22); + var buf: [8]u8 = undefined; + // Date 720 days before end_date: still boundary-inclusive → DD MMM. + const lbl = br.fmtAxisDate(Date.fromYmd(2025, 1, 1), &buf); + try std.testing.expectEqualStrings("01 Jan", lbl); +} + +test "fmtAxisDate: 721 days before end flips to MMM YYYY" { + var br: BrailleChart = undefined; + br.start_date = Date.fromYmd(2024, 12, 31); + // 721 days after 2024-12-31 = 2026-12-22. + br.end_date = Date.fromYmd(2026, 12, 22); + var buf: [8]u8 = undefined; + // Format the start (which is 721 days before end_date). + const lbl = br.fmtAxisDate(br.start_date, &buf); + try std.testing.expectEqualStrings("Dec 2024", lbl); +} + +test "displayCols: ASCII bytes count as 1 col each" { + try std.testing.expectEqual(@as(usize, 0), displayCols("")); + try std.testing.expectEqual(@as(usize, 5), displayCols("hello")); + try std.testing.expectEqual(@as(usize, 10), displayCols("2026-05-08")); +} + +test "displayCols: multibyte UTF-8 chars count as 1 col" { + // U+25B6 BLACK RIGHT-POINTING TRIANGLE = 3 bytes / 1 col. + try std.testing.expectEqual(@as(usize, 1), displayCols("▶")); + try std.testing.expectEqual(@as(usize, 1), displayCols("▼")); + // U+2014 EM DASH = 3 bytes / 1 col. + try std.testing.expectEqual(@as(usize, 1), displayCols("—")); + // Mixed. + try std.testing.expectEqual(@as(usize, 17), displayCols("▶ W of 2026-04-22")); + try std.testing.expectEqual(@as(usize, 10), displayCols("▶ Jan 2026")); + try std.testing.expectEqual(@as(usize, 6), displayCols("▶ 2024")); +} + +test "padRightToCols: ASCII content pads to target" { + var buf: [16]u8 = undefined; + @memcpy(buf[0..2], "hi"); + const out = padRightToCols(&buf, buf[0..2], 5); + try std.testing.expectEqualStrings("hi ", out); +} + +test "padRightToCols: multibyte content pads to display width" { + var buf: [16]u8 = undefined; + const dash = "—"; + @memcpy(buf[0..dash.len], dash); + // Em-dash is 1 col / 3 bytes. Target 5 cols → 4 trailing spaces. + // Total bytes: 3 + 4 = 7. + const out = padRightToCols(&buf, buf[0..dash.len], 5); + try std.testing.expectEqual(@as(usize, 7), out.len); + try std.testing.expectEqualStrings("— ", out); +} + +test "padRightToCols: content already at-or-beyond target returns as-is" { + var buf: [16]u8 = undefined; + @memcpy(buf[0..5], "hello"); + const out = padRightToCols(&buf, buf[0..5], 5); + try std.testing.expectEqualStrings("hello", out); + const out2 = padRightToCols(&buf, buf[0..5], 3); + try std.testing.expectEqualStrings("hello", out2); +} + +test "centerDash: even width centers dash with equal padding" { + var buf: [16]u8 = undefined; + const out = centerDash(&buf, 6); + // 6 cols: pad = (6-1)/2 = 2 left spaces, then dash (1 col / 3 + // bytes), then 3 right spaces. Total: 6 cols = 8 bytes. + try std.testing.expectEqual(@as(usize, 6), displayCols(out)); + try std.testing.expectEqual(@as(usize, 8), out.len); + try std.testing.expectEqualStrings(" — ", out); +} + +test "centerDash: odd width biases dash one col left of center" { + var buf: [16]u8 = undefined; + const out = centerDash(&buf, 5); + // 5 cols: pad = (5-1)/2 = 2 left spaces, dash, 2 right spaces. + // Total: 5 cols = 7 bytes. + try std.testing.expectEqual(@as(usize, 5), displayCols(out)); + try std.testing.expectEqual(@as(usize, 7), out.len); + try std.testing.expectEqualStrings(" — ", out); +} + +test "centerDash: width 1 emits dash with no padding" { + var buf: [8]u8 = undefined; + const out = centerDash(&buf, 1); + // pad = (1-1)/2 = 0: no left spaces, dash, no right spaces. + try std.testing.expectEqual(@as(usize, 1), displayCols(out)); + try std.testing.expectEqualStrings("—", out); +} + +test "centerDash: width 0 emits empty slice" { + var buf: [8]u8 = undefined; + const out = centerDash(&buf, 0); + // (0 -| 1) / 2 = 0: no left spaces. The `if pos + dash.len + // <= buf.len` does write the dash though, but then + // padRightToCols sees content with display-width 1 against + // a target of 0 and returns the content as-is. So we get a + // bare dash even at width 0. Degenerate case the renderer + // never hits in practice (cell widths are always > 0); lock + // in current behavior. + try std.testing.expectEqualStrings("—", out); +} + +test "centerDash: typical history-table cell width (31 cols)" { + // This is the actual table_cell_width used in the History + // tab — em-dash centered in 31 columns. + var buf: [40]u8 = undefined; + const out = centerDash(&buf, 31); + // pad = 15, dash (1 col), 15 right spaces. 31 cols = 33 bytes. + try std.testing.expectEqual(@as(usize, 31), displayCols(out)); + try std.testing.expectEqual(@as(usize, 33), out.len); + try std.testing.expectEqualStrings(" — ", out); +} + +test "centerDash: undersized buffer returns less than `width` cols" { + // Defensive: buffer too small to hold the full padded cell. + // Function falls back to whatever fits without overflowing. + var buf: [4]u8 = undefined; + const out = centerDash(&buf, 10); + // pad = 4 spaces wanted but only 4-byte buf — left-loop fills + // to pos=4, then `pos + dash.len <= buf.len` is `4+3<=4` = + // false, so dash isn't written. Trailing-pad helper sees + // content with 4 cols against target 10, and 4+pad>buf.len + // so it returns content unchanged. Output is 4 spaces. + try std.testing.expectEqualStrings(" ", out); +} diff --git a/src/history.zig b/src/history.zig index 2d8fdb6..cee9ea8 100644 --- a/src/history.zig +++ b/src/history.zig @@ -37,6 +37,7 @@ const Date = @import("models/date.zig").Date; const Candle = @import("models/candle.zig").Candle; const timeline = @import("analytics/timeline.zig"); const valuation = @import("analytics/valuation.zig"); +const imported_values = @import("data/imported_values.zig"); pub const Error = error{ /// The file didn't open a `#!srfv1` directive or couldn't be @@ -282,7 +283,23 @@ pub fn loadTimeline( var loaded = try loadHistoryDir(io, allocator, history_dir); errdefer loaded.deinit(); - const series = try timeline.buildSeries(allocator, loaded.snapshots); + // Merge in imported_values.srf, if present. Missing file is + // not an error — produces an empty merge. + const iv_path = try std.fs.path.join(allocator, &.{ history_dir, "imported_values.srf" }); + defer allocator.free(iv_path); + + var iv = try imported_values.loadImportedValues(io, allocator, iv_path); + defer iv.deinit(); + + // Translate to the analytics-layer's minimal type so timeline.zig + // doesn't need to import data/imported_values.zig. + const adapted = try allocator.alloc(timeline.ImportedHistoryPoint, iv.points.len); + defer allocator.free(adapted); + for (iv.points, 0..) |p, i| { + adapted[i] = .{ .date = p.date, .liquid = p.liquid }; + } + + const series = try timeline.buildMergedSeries(allocator, loaded.snapshots, adapted); return .{ .loaded = loaded, diff --git a/src/main.zig b/src/main.zig index 9d496fa..b393a9f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -25,6 +25,7 @@ const usage = \\ lookup Look up CUSIP to ticker via OpenFIGI \\ audit [opts] Reconcile portfolio against brokerage export \\ projections [opts] Retirement projections and benchmark comparison + \\ milestones [opts] Show portfolio threshold crossings (e.g. each $1M, doublings) \\ cache stats Show cache statistics \\ cache clear Clear all cached data \\ version [-v] Show zfin version and build info @@ -101,6 +102,13 @@ const usage = \\ deltas. Combine with --as-of to compare two \\ historical dates (--vs = then, --as-of = now). \\ + \\Milestones command options: + \\ --step Threshold step. Required. + \\ Absolute: 1M, 1m, 1500000, 1.5M, 500K, 500k + \\ Relative: 2x, 2X, 1.5x (must be > 1.0) + \\ --real Deflate the series to the last full Shiller year + \\ before detecting crossings (CPI-adjusted). + \\ \\Compare command options: \\ --projections Include projected return + safe-withdrawal @99% \\ deltas between the attribution rows and the @@ -686,6 +694,17 @@ fn runCli(init: std.process.Init) !u8 { => return 1, else => return err, }; + } else if (std.mem.eql(u8, command, "milestones")) { + const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); + defer if (pf.resolved) |r| r.deinit(allocator); + commands.milestones.run(io, allocator, &svc, pf.path, cmd_args, today, color, out) catch |err| switch (err) { + error.UnexpectedArg, + error.MissingStep, + error.InvalidStep, + error.NoData, + => return 1, + else => return err, + }; } else { try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n"); return 1; @@ -761,6 +780,7 @@ const commands = struct { const compare = @import("commands/compare.zig"); const version = @import("commands/version.zig"); const projections = @import("commands/projections.zig"); + const milestones = @import("commands/milestones.zig"); }; // ── Tests ──────────────────────────────────────────────────── @@ -817,14 +837,19 @@ test "parseGlobals: subcommand-local flag NOT consumed as global" { // Single test binary: all source is in one module (file imports, no module // boundaries). `std.testing.refAllDecls(@This())` walks main.zig's top-level -// decls; explicit `_ = @import(...)` lines below cover files reachable only -// indirectly (e.g. via non-pub re-exports, or through types extracted by -// signature without the file's struct itself ever being walked). +// decls, which transitively pulls in every file imported (directly or +// indirectly) via a `const x = @import(...)` form. As long as a file is +// reachable that way through the import graph, its `test` blocks are +// collected by the test runner — no explicit `_ = @import(...)` lines +// required here. // -// To find missing imports: comment out a candidate, run `zig build test -// --summary all`, and watch the test count. If it drops, the import was -// load-bearing. Per AGENTS.md, this list is the minimum set; do not add -// imports speculatively. +// If a new `.zig` file's tests aren't being discovered (test count doesn't +// rise after adding a file with tests), the cause is almost always that +// the file is only referenced via a *type extraction* like +// `const T = @import("foo.zig").T;` — that form pulls in the type but +// doesn't sema-touch the file struct, so its tests are skipped. Fix the +// importer to do `const foo = @import("foo.zig");` instead. See AGENTS.md +// "Test discovery" for the canary procedure. test { std.testing.refAllDecls(@This()); diff --git a/src/models/date.zig b/src/models/date.zig index f8a88f4..8a97182 100644 --- a/src/models/date.zig +++ b/src/models/date.zig @@ -124,12 +124,32 @@ pub const Date = struct { } } + /// Last day of `(y, m)`. E.g., `lastDayOfMonth(2024, 2)` + /// returns `2024-02-29`. `m` must be 1..12; values outside + /// that range trigger `unreachable` in `daysInMonth`. + pub fn lastDayOfMonth(y: i16, m: u8) Date { + return fromYmd(y, m, daysInMonth(y, m)); + } + fn daysInMonth(y: i16, m: u8) u8 { const table = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 }; if (m == 2 and isLeapYear(y)) return 29; return table[m - 1]; } + /// Three-letter English abbreviation of a month number + /// (`1` → `"Jan"`, `12` → `"Dec"`). Returns `"???"` for + /// out-of-range input rather than panicking — display + /// helpers prefer a placeholder over a crash. + pub fn monthShort(m: u8) []const u8 { + const table = [_][]const u8{ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + }; + if (m < 1 or m > 12) return "???"; + return table[m - 1]; + } + /// Returns approximate number of years between two dates pub fn yearsBetween(from: Date, to: Date) f64 { return @as(f64, @floatFromInt(to.days - from.days)) / 365.25; @@ -237,6 +257,23 @@ test "lastDayOfPriorMonth" { try std.testing.expectEqual(@as(u8, 28), d4.day()); } +test "lastDayOfMonth" { + // 31-day month. + try std.testing.expectEqual(Date.fromYmd(2026, 1, 31), Date.lastDayOfMonth(2026, 1)); + // 30-day month. + try std.testing.expectEqual(Date.fromYmd(2026, 4, 30), Date.lastDayOfMonth(2026, 4)); + // Feb in leap year. + try std.testing.expectEqual(Date.fromYmd(2024, 2, 29), Date.lastDayOfMonth(2024, 2)); + // Feb in non-leap year. + try std.testing.expectEqual(Date.fromYmd(2025, 2, 28), Date.lastDayOfMonth(2025, 2)); + // Century non-leap (divisible by 100 but not 400). + try std.testing.expectEqual(Date.fromYmd(2100, 2, 28), Date.lastDayOfMonth(2100, 2)); + // 400-year leap. + try std.testing.expectEqual(Date.fromYmd(2000, 2, 29), Date.lastDayOfMonth(2000, 2)); + // December. + try std.testing.expectEqual(Date.fromYmd(2026, 12, 31), Date.lastDayOfMonth(2026, 12)); +} + test "dayOfWeek" { // 1970-01-01 was a Thursday (3 in 0=Mon scheme) try std.testing.expectEqual(@as(u8, 3), Date.fromYmd(1970, 1, 1).dayOfWeek()); diff --git a/src/tui.zig b/src/tui.zig index 8cf10be..897b5f8 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -473,10 +473,18 @@ pub const App = struct { // liquid anyway, so "show me liquid" is the headline view. history_metric: timeline.Metric = .liquid, /// Forced resolution for the history table + chart. Null means - /// "auto" — `timeline.selectResolution` picks daily/weekly/monthly - /// based on the span of the loaded series. Cycled via the - /// `history_resolution_next` keybind ('t' by default). + /// "default" — interpreted as cascading by the renderer. Cycled + /// via the `history_resolution_next` keybind ('t' by default). history_resolution: ?timeline.Resolution = null, + /// Buckets that the user has explicitly expanded in the + /// cascading-view recent-snapshots table. Keyed by + /// `(tier, bucket_start.days)` so that a parent and its + /// edge-aligned child (e.g. yearly 2024 starts on the same + /// day as quarterly Q1 2024) are distinct. + /// Default: empty — every bucket is collapsed at the + /// drilldown level. Daily rows for the last 14 days are + /// always shown inline. + history_expanded_buckets: std.AutoHashMap(history_tab.BucketKey, void) = undefined, // Mouse wheel debounce for cursor-based tabs (portfolio, options). // Terminals often send multiple wheel events per physical tick. @@ -688,6 +696,22 @@ pub const App = struct { } } } + // History tab: click a tier header to expand/collapse; + // click a bucket/snapshot row to move the cursor. + if (self.active_tab == .history and self.history_compare_view == null and mouse.row > 0) { + const content_row = @as(usize, @intCast(mouse.row)) + self.scroll_offset; + if (content_row >= self.history_table_first_line and self.history_table_row_count > 0) { + const row_idx = content_row - self.history_table_first_line; + if (row_idx < self.history_table_row_count) { + // Move the cursor to the clicked row, then + // try to toggle if it's a tier header. Both + // outcomes consume the click and redraw. + self.history_cursor = row_idx; + _ = history_tab.toggleTierAtCursor(self); + return ctx.consumeAndRedraw(); + } + } + } }, else => {}, } @@ -1173,6 +1197,10 @@ pub const App = struct { } else if (self.active_tab == .options) { self.toggleOptionsExpand(); return ctx.consumeAndRedraw(); + } else if (self.active_tab == .history) { + if (history_tab.toggleTierAtCursor(self)) { + return ctx.consumeAndRedraw(); + } } }, .scroll_down => { @@ -2303,10 +2331,10 @@ pub fn renderBrailleToStyledLines(arena: std.mem.Allocator, lines: *std.ArrayLis // Date axis below chart { - var start_buf: [7]u8 = undefined; - var end_buf: [7]u8 = undefined; - const start_label = fmt.BrailleChart.fmtShortDate(br.start_date, &start_buf); - const end_label = fmt.BrailleChart.fmtShortDate(br.end_date, &end_buf); + var start_buf: [8]u8 = undefined; + var end_buf: [8]u8 = undefined; + const start_label = br.fmtAxisDate(br.start_date, &start_buf); + const end_label = br.fmtAxisDate(br.end_date, &end_buf); const muted_style = vaxis.Style{ .fg = theme.Theme.vcolor(th.text_muted), .bg = theme.Theme.vcolor(bg) }; const date_graphemes = try arena.alloc([]const u8, br.n_cols + 12); @@ -2475,7 +2503,9 @@ pub fn run( .symbol = symbol, .has_explicit_symbol = has_explicit_symbol, .chart = .{ .config = chart_config }, + .history_expanded_buckets = std.AutoHashMap(history_tab.BucketKey, void).init(allocator), }; + defer app_inst.history_expanded_buckets.deinit(); if (portfolio_path) |path| { const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch null; diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 83a53eb..8cf44a7 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -48,6 +48,27 @@ const compare_view = @import("../views/compare.zig"); const App = tui.App; const StyledLine = tui.StyledLine; +/// Composite key for `App.history_expanded_buckets`. Keying by +/// `bucket_start.days` alone collides on edge-aligned parents +/// and children — e.g. yearly 2024 starts on 2024-01-01, and so +/// does its child quarterly Q1 2024. Tagging by tier +/// disambiguates so expanding the parent doesn't auto-expand +/// the child. +pub const BucketKey = struct { + tier: timeline.Tier, + days: i32, +}; + +fn keyFor(b: timeline.TierBucket) BucketKey { + return .{ .tier = b.tier, .days = b.bucket_start.days }; +} + +fn keyForRow(row: TableRow) ?BucketKey { + const t = row.tier orelse return null; + const start = row.bucket_start orelse return null; + return .{ .tier = t, .days = start.days }; +} + // ── Data loading ────────────────────────────────────────────── pub fn loadData(app: *App) void { @@ -100,18 +121,46 @@ pub fn cycleMetric(app: *App) void { }; } -/// Cycle resolution: auto → daily → weekly → monthly → auto. +/// Cycle resolution: cascading → daily → weekly → monthly → cascading. pub fn cycleResolution(app: *App) void { app.history_resolution = switch (app.history_resolution orelse { + // null means "default" — i.e. cascading. Step to daily. app.history_resolution = .daily; return; }) { + .cascading => .daily, .daily => .weekly, .weekly => .monthly, - .monthly => null, // back to auto + .monthly => null, // back to cascading default }; } +/// If the cursor is on an expandable (non-daily, non-live) +/// bucket row, toggle its expanded state. Returns true if a +/// toggle happened (so the caller knows to consume the keypress +/// and trigger a redraw). +pub fn toggleTierAtCursor(app: *App) bool { + var arena_state = std.heap.ArenaAllocator.init(app.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const rows = collectTableRows(arena, app) catch return false; + if (app.history_cursor >= rows.len) return false; + const row = rows[app.history_cursor]; + + // Only buckets with a finer tier are expandable. Live rows, + // daily rows, and rows without bucket_start are not. + if (!row.has_children) return false; + const key = keyForRow(row) orelse return false; + + if (app.history_expanded_buckets.contains(key)) { + _ = app.history_expanded_buckets.remove(key); + } else { + app.history_expanded_buckets.put(key, {}) catch return false; + } + return true; +} + // ── Compare selection model ────────────────────────────────── /// Returns the number of currently selected rows (0, 1, or 2). @@ -217,7 +266,16 @@ fn commitCompare(app: *App) void { buildCompareFromSelections(app, sel_a, sel_b) catch |err| { var msg_buf: [128]u8 = undefined; - const msg = std.fmt.bufPrint(&msg_buf, "Compare failed: {s}", .{@errorName(err)}) catch "Compare failed"; + // Translate the most common failure modes into actionable + // messages. We already short-circuit imported-only rows + // inside `buildCompareFromSelections`, so reaching the + // FileNotFound arm here means a snapshot the row claimed + // to have got moved or deleted between row collection + // and snapshot load. + const msg = switch (err) { + error.FileNotFound => "Compare failed: snapshot file is missing", + else => std.fmt.bufPrint(&msg_buf, "Compare failed: {s}", .{@errorName(err)}) catch "Compare failed", + }; app.setStatus(msg); clearCompareView(app); }; @@ -246,6 +304,17 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void { const older = if (row_a.date.days < row_b.date.days) row_a else row_b; const newer = if (row_a.date.days < row_b.date.days) row_b else row_a; + // Imported-only rows have no on-disk snapshot — the historical + // value came from `imported_values.srf`, not a real portfolio + // snapshot. We can't build a per-symbol compare from that + // (no lots, no per-symbol prices). Bail out with a friendly + // status before we try (and fail) to open the snapshot file. + if (older.imported_only or newer.imported_only) { + app.setStatus("Cannot compare: imported-only history has no per-symbol detail"); + clearSelections(app); + return; + } + // Build up the resources + maps for each side. var resources: tui.HistoryCompareResources = .{}; errdefer resources.deinit(app.allocator); @@ -312,10 +381,30 @@ fn buildCompareFromSelections(app: *App, sel_a: usize, sel_b: usize) !void { // If buildCompareView returned Ok, we own cv's backing memory // (allocated inside buildCompareView) and pass ownership to the App. + // Bucket-aware labels: when either selected row was a tier + // bucket (weekly/monthly/quarterly/yearly), render + // "Q1 2025 (ended 2025-03-28)" instead of the bare ISO date. + // Allocates into app.allocator; CompareView.deinit frees. + var cv_with_labels = cv; + cv_with_labels.then_label = try compare_view.buildBucketLabel( + app.allocator, + older.tier, + older.bucket_start, + older.date, + older.is_live, + ); + cv_with_labels.now_label = try compare_view.buildBucketLabel( + app.allocator, + newer.tier, + newer.bucket_start, + newer.date, + newer.is_live, + ); + // Commit: install both onto the App. From this point onwards the // App owns them and clearCompareView handles teardown. clearCompareView(app); - app.history_compare_view = cv; + app.history_compare_view = cv_with_labels; app.history_compare_resources = resources; app.setStatus("Comparing — Esc or 'c' to return to timeline"); } @@ -402,8 +491,28 @@ pub fn handleCompareKey(app: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key /// One row in the rendered recent-snapshots table. /// /// `is_live` is true for the synthesized "today (live)" pseudo-row -/// at index 0 when the TUI has portfolio-summary state loaded. All -/// other rows correspond to real snapshots, one per `RowDelta`. +/// at index 0 when the TUI has portfolio-summary state loaded. +/// +/// `tier`, when set on a non-header row, identifies which tier +/// the bucket belongs to (used to render a date label like +/// "W of YYYY-MM-DD" / "Q1 YYYY") and to decide drill-down +/// behavior. +/// +/// `imported_only` flags rows where illiquid/net_worth are +/// unavailable (data came from imported_values.srf). Renderers +/// substitute `—` for those cells. +/// +/// `expanded` and `has_children` drive the per-bucket drill-down. +/// A non-daily bucket can be expanded (clicked / Enter on cursor) +/// to reveal its child buckets at the next-finer tier. +/// +/// `indent` is how deep the row sits in the drill-down tree +/// (0 = top-level, 1 = first drill-down level, etc.). The +/// renderer uses this to indent the date column visually. +/// +/// `tier_header` is retained for API compatibility but no +/// longer populated by the cascading collector — every row in +/// the new model is a bucket row. pub const TableRow = struct { date: zfin.Date, is_live: bool, @@ -413,6 +522,30 @@ pub const TableRow = struct { d_liquid: ?f64, d_illiquid: ?f64, d_net_worth: ?f64, + /// Deprecated — always null in the cascading collector. Kept + /// for compatibility with older test fixtures. + tier_header: ?TierHeader = null, + tier: ?timeline.Tier = null, + imported_only: bool = false, + bucket_start: ?zfin.Date = null, + bucket_end: ?zfin.Date = null, + /// True when the user has expanded this bucket. Daily and + /// imported_only-only buckets cannot be expanded. + expanded: bool = false, + /// True when this bucket has a finer-tier breakdown + /// available (any non-daily tier has children). + has_children: bool = false, + /// Drill-down depth. 0 for top-level rows; >0 for rows + /// emitted as children of an expanded bucket. + indent: u8 = 0, +}; + +/// Tier header metadata. Rendering displays a chevron + tier +/// label + bucket count. +pub const TierHeader = struct { + tier: timeline.Tier, + collapsed: bool, + bucket_count: u32, }; /// Build the list of table rows in display order (newest-first). @@ -427,7 +560,27 @@ pub fn collectTableRows(arena: std.mem.Allocator, app: *const App) ![]TableRow { const series = timeline_opt.?.series; if (series.points.len == 0) return &.{}; - const resolution = app.history_resolution orelse timeline.selectResolution(series.points); + // Resolution dispatch: + // - explicit non-cascading: use the legacy flat-aggregation + // path (preserves existing behavior for daily/weekly/monthly). + // - explicit cascading OR null (default): build the tiered view. + const explicit = app.history_resolution; + const use_cascading = explicit == null or explicit.? == .cascading; + + if (!use_cascading) { + return collectFlatTableRows(arena, app, series, explicit.?); + } + return collectCascadingTableRows(arena, app, series); +} + +/// Legacy path: flat aggregation by single resolution. Preserved +/// for `--resolution daily/weekly/monthly` invocations. +fn collectFlatTableRows( + arena: std.mem.Allocator, + app: *const App, + series: timeline.TimelineSeries, + resolution: timeline.Resolution, +) ![]TableRow { const aggregated = try timeline.aggregatePoints(arena, series.points, resolution); const deltas = try timeline.computeRowDeltas(arena, aggregated); @@ -460,6 +613,177 @@ pub fn collectTableRows(arena: std.mem.Allocator, app: *const App) ![]TableRow { return list.toOwnedSlice(arena); } +/// New path: cascading aggregation. Produces a flat row list +/// where each top-level bucket is its own row; expanded buckets +/// are followed by their child buckets at the next-finer tier +/// (recursively, so an expanded year reveals quarters which can +/// reveal months which can reveal weeks which can reveal days). +/// +/// Daily rows for the last 14 days are top-level (no parent +/// bucket — they're already at leaf granularity). +fn collectCascadingTableRows( + arena: std.mem.Allocator, + app: *const App, + series: timeline.TimelineSeries, +) ![]TableRow { + const ts = try timeline.aggregateCascading(arena, series.points, app.today); + + // Live row (today's live state) — same as flat path. + const live_opt = buildLiveRowFromCascading(app, ts.buckets); + + var list: std.ArrayList(TableRow) = .empty; + try list.ensureTotalCapacity(arena, ts.buckets.len + 8); + + if (live_opt) |live| try list.append(arena, live); + + // Pre-compute Δs for the top-level buckets so each row + // shows "this bucket vs. its older neighbor." + const top_deltas = try timeline.computeBucketDeltas(arena, ts.buckets); + + for (ts.buckets, 0..) |b, idx| { + const d = top_deltas[idx]; + const expanded = app.history_expanded_buckets.contains(keyFor(b)); + try list.append(arena, .{ + .date = b.representative_date, + .is_live = false, + .liquid = b.liquid, + .illiquid = b.illiquid, + .net_worth = b.net_worth, + .d_liquid = d.delta_liquid, + .d_illiquid = d.delta_illiquid, + .d_net_worth = d.delta_net_worth, + .tier_header = null, + .tier = b.tier, + .imported_only = b.imported_only, + .bucket_start = b.bucket_start, + .bucket_end = b.bucket_end, + .expanded = expanded, + .has_children = timeline.finerTier(b.tier) != null, + .indent = 0, + }); + + if (expanded) { + try emitChildren(arena, app, series.points, b, &list, 1); + } + } + + return list.toOwnedSlice(arena); +} + +/// Recursively emit child rows for a parent bucket. Used both +/// at top level and when a child is itself expanded. +fn emitChildren( + arena: std.mem.Allocator, + app: *const App, + series: []const timeline.TimelinePoint, + parent: timeline.TierBucket, + list: *std.ArrayList(TableRow), + indent: u8, +) !void { + const children = try timeline.childBuckets(arena, parent); + if (children.len == 0) return; + const child_deltas = try timeline.computeBucketDeltas(arena, children); + + // The oldest child gets a null Δ from `computeBucketDeltas` + // because there's no older neighbor inside the local slice. + // But within the wider series there usually IS an older + // point (just before `parent.bucket_start`); use it so the + // bottom row shows a meaningful Δ instead of greyed-out null. + // Only the very-first data point in the entire series should + // legitimately have a null Δ. + const oldest_idx = children.len - 1; + const oldest = children[oldest_idx]; + const prior_opt = priorPointBefore(series, oldest.bucket_start); + const oldest_delta: timeline.BucketDelta = if (prior_opt) |prior| blk: { + const dl: ?f64 = oldest.liquid - prior.liquid; + // Imported-only crossings still null out illiquid / + // net_worth Δs. + const cross_imported = oldest.imported_only or prior.source == .imported; + const di: ?f64 = if (cross_imported) null else oldest.illiquid - prior.illiquid; + const dn: ?f64 = if (cross_imported) null else oldest.net_worth - prior.net_worth; + break :blk .{ .delta_liquid = dl, .delta_illiquid = di, .delta_net_worth = dn }; + } else child_deltas[oldest_idx]; + + for (children, 0..) |c, idx| { + const d = if (idx == oldest_idx) oldest_delta else child_deltas[idx]; + const expanded = app.history_expanded_buckets.contains(keyFor(c)); + try list.append(arena, .{ + .date = c.representative_date, + .is_live = false, + .liquid = c.liquid, + .illiquid = c.illiquid, + .net_worth = c.net_worth, + .d_liquid = d.delta_liquid, + .d_illiquid = d.delta_illiquid, + .d_net_worth = d.delta_net_worth, + .tier_header = null, + .tier = c.tier, + .imported_only = c.imported_only, + .bucket_start = c.bucket_start, + .bucket_end = c.bucket_end, + .expanded = expanded, + .has_children = timeline.finerTier(c.tier) != null, + .indent = indent, + }); + + if (expanded and timeline.finerTier(c.tier) != null) { + try emitChildren(arena, app, series, c, list, indent + 1); + } + } +} + +/// Find the most recent series point with `as_of_date < target`. +/// Returns null when `target` precedes every point in the series +/// — i.e., this would be the very first data point overall, the +/// only case where a null Δ is correct. +/// +/// `series` is date-ascending (built that way by +/// `timeline.buildMergedSeries`); we walk backward from the end +/// for an early exit on typical drill-down workloads where the +/// target is recent. +fn priorPointBefore(series: []const timeline.TimelinePoint, target: zfin.Date) ?timeline.TimelinePoint { + var i: usize = series.len; + while (i > 0) { + i -= 1; + if (series[i].as_of_date.days < target.days) return series[i]; + } + return null; +} + +/// Build the live row for the cascading path. Uses the newest +/// bucket (typically a daily bucket) as the comparison anchor. +fn buildLiveRowFromCascading(app: *const App, buckets: []const timeline.TierBucket) ?TableRow { + if (app.portfolio == null) return null; + const summary = app.portfolio_summary orelse return null; + + const liquid = summary.total_value; + const illiquid = app.portfolio.?.totalIlliquid(app.today); + const net_worth = liquid + illiquid; + + var d_liquid: ?f64 = null; + var d_illiquid: ?f64 = null; + var d_net_worth: ?f64 = null; + if (buckets.len > 0) { + const b = buckets[0]; + d_liquid = liquid - b.liquid; + if (!b.imported_only) { + d_illiquid = illiquid - b.illiquid; + d_net_worth = net_worth - b.net_worth; + } + } + + return .{ + .date = app.today, + .is_live = true, + .liquid = liquid, + .illiquid = illiquid, + .net_worth = net_worth, + .d_liquid = d_liquid, + .d_illiquid = d_illiquid, + .d_net_worth = d_net_worth, + }; +} + /// Build the synthetic "today (live)" row from App state, or return /// null if the required state isn't populated. /// @@ -647,10 +971,16 @@ pub fn renderHistoryLinesFull( try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // Chart: synthesize candles from the focused metric's value. - const candles = try arena.alloc(zfin.Candle, points.len); - for (points, 0..) |p, i| { + // For illiquid / net_worth, skip imported-only points so the + // line is visually absent in the imported-only range rather + // than hugging zero. + var candles_list: std.ArrayList(zfin.Candle) = .empty; + try candles_list.ensureTotalCapacity(arena, points.len); + const skip_imported = (focus_metric == .illiquid) or (focus_metric == .net_worth); + for (points) |p| { + if (skip_imported and p.source == .imported) continue; const value = extractOne(p, focus_metric); - candles[i] = .{ + try candles_list.append(arena, .{ .date = p.as_of_date, .open = value, .high = value, @@ -658,30 +988,37 @@ pub fn renderHistoryLinesFull( .close = value, .adj_close = value, .volume = 0, - }; + }); } + const candles = candles_list.items; try tui.renderBrailleToStyledLines(arena, &lines, candles, th); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); // ── Recent snapshots table ─────────────────────────────────── - const resolution = resolution_override orelse timeline.selectResolution(points); + // Resolve the displayed resolution label: explicit override + // wins, otherwise default is cascading (matches the + // rendering in `collectTableRows`). + const resolution: timeline.Resolution = if (resolution_override) |r| r else .cascading; var rlabel_buf: [32]u8 = undefined; const rlabel = view.fmtResolutionLabel(&rlabel_buf, resolution_override, resolution); const table_header = try std.fmt.allocPrint( arena, - " Recent snapshots {s} (j/k: move, s/space: select, c: compare)", + " Recent snapshots {s} (j/k: move, enter: expand/collapse, s/space: select, c: compare)", .{rlabel}, ); try lines.append(arena, .{ .text = table_header, .style = th.headerStyle() }); try lines.append(arena, .{ .text = "", .style = th.contentStyle() }); - // Column header: extra 2-char left margin for the selection marker. + // Column header: extra 2-char left margin for the selection + // marker; "Date" itself indented 2 more chars so the column + // text aligns with bucket date labels (which carry a 2-space + // chevron-or-spacer prefix). 28-col slot matches `fmtTableRow`. const header_line = try std.fmt.allocPrint( arena, - " {s:>10} {s:>28} {s:>28} {s:>28}", - .{ "Date", "Liquid (Δ)", "Illiquid (Δ)", "Net Worth (Δ)" }, + " {s:<28} {s:>31} {s:>31} {s:>31}", + .{ " Date", "Liquid (Δ)", "Illiquid (Δ)", "Net Worth (Δ)" }, ); try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() }); @@ -736,24 +1073,25 @@ fn appendWindowsBlock( const header_line = try std.fmt.allocPrint( arena, - " {s:<12} {s:>18} {s:>10}", - .{ "", "Δ", "%" }, + " {s:<12} {s:>18} {s:>10} {s:>10}", + .{ "", "Δ", "%", "% / yr" }, ); try lines.append(arena, .{ .text = header_line, .style = th.mutedStyle() }); try lines.append(arena, .{ - .text = " ------------ ------------------ ----------", + .text = " ------------ ------------------ ---------- ----------", .style = th.mutedStyle(), }); for (ws.rows) |row| { var dbuf: [32]u8 = undefined; var pbuf: [16]u8 = undefined; - const cells = view.buildWindowRowCells(row, &dbuf, &pbuf); + var abuf: [16]u8 = undefined; + const cells = view.buildWindowRowCells(row, &dbuf, &pbuf, &abuf); const text = try std.fmt.allocPrint( arena, - " {s:<12} {s:>18} {s:>10}", - .{ cells.label, cells.delta_str, cells.pct_str }, + " {s:<12} {s:>18} {s:>10} {s:>10}", + .{ cells.label, cells.delta_str, cells.pct_str, cells.ann_str }, ); const style: vaxis.Cell.Style = th.styleFor(cells.style); try lines.append(arena, .{ .text = text, .style = style }); @@ -762,31 +1100,107 @@ fn appendWindowsBlock( /// Build a recent-snapshots table row. `selected` causes a `*` marker /// to appear in the two-column left margin instead of spaces. +/// +/// All rows align column-for-column regardless of drill-down depth: +/// the value cells (Liquid/Illiquid/Net Worth) sit at fixed column +/// positions so their closing `)` characters line up vertically +/// across the whole table. +/// +/// Drill-down depth is conveyed inside the date column only — by +/// indenting the chevron+label within the 20-byte date slot. The +/// trade-off: if a deeply-drilled label overflows 20 bytes (e.g. +/// ` ▶ W of 2024-03-31`), the value columns shift right just +/// for that row. We mitigate by capping the per-level indent at +/// 2 columns and shrinking the slot use. fn fmtTableRow(arena: std.mem.Allocator, row: TableRow, selected: bool) ![]const u8 { - var date_buf: [10]u8 = undefined; + // Legacy: tier_header rows (now unused). Kept so old test + // fixtures still render something sensible. + if (row.tier_header) |th| { + const chevron: []const u8 = if (th.collapsed) "▶" else "▼"; + return std.fmt.allocPrint( + arena, + " {s} {s} ({d})", + .{ chevron, @tagName(th.tier), th.bucket_count }, + ); + } + + var date_buf: [40]u8 = undefined; var liq_cell_buf: [64]u8 = undefined; var ill_cell_buf: [64]u8 = undefined; var nw_cell_buf: [64]u8 = undefined; - // Live row: replace the date column with "today (live)" right-aligned to 10. + // Build the date cell content. Drill-down depth is shown by + // leading spaces *inside* the date slot, NOT by indenting + // the whole row — so values stay aligned column-for-column. const date_s: []const u8 = if (row.is_live) - "today" - else - row.date.format(&date_buf); + // Write into `date_buf` so the in-place padding below + // can extend it. + std.fmt.bufPrint(&date_buf, " today", .{}) catch " today" + else if (row.tier) |t| blk: { + var inner_buf: [32]u8 = undefined; + // Bucket rows from cascading mode carry `bucket_start`; + // legacy daily rows from flat-resolution don't, so we + // fall back to `row.date` (the row's representative + // date — same value as bucket_start for daily buckets). + const start = row.bucket_start orelse row.date; + const lbl = timeline.formatBucketLabel(&inner_buf, t, start); + // Compute indent prefix: 2 spaces per indent level, capped + // at 16 to keep the date cell from blowing past its 28-col + // budget. (Trees go 4-5 levels max in practice.) + const indent_pool = " "; // 16 spaces + const indent_cols = @min(@as(usize, row.indent) * 2, indent_pool.len); + const lead = indent_pool[0..indent_cols]; + if (row.has_children) { + const chev: []const u8 = if (row.expanded) "▼" else "▶"; + break :blk std.fmt.bufPrint( + &date_buf, + "{s}{s} {s}", + .{ lead, chev, lbl }, + ) catch lbl; + } + // No expand chevron: 2 leading spaces (to align with + // chevron-bearing rows at the same indent level). + break :blk std.fmt.bufPrint( + &date_buf, + "{s} {s}", + .{ lead, lbl }, + ) catch lbl; + } else blk: { + var iso_buf: [10]u8 = undefined; + const iso = row.date.format(&iso_buf); + break :blk std.fmt.bufPrint(&date_buf, " {s}", .{iso}) catch iso; + }; const liq_cell = view.fmtValueDeltaCell(&liq_cell_buf, row.liquid, row.d_liquid, view.table_cell_width); - const ill_cell = view.fmtValueDeltaCell(&ill_cell_buf, row.illiquid, row.d_illiquid, view.table_cell_width); - const nw_cell = view.fmtValueDeltaCell(&nw_cell_buf, row.net_worth, row.d_net_worth, view.table_cell_width); + const ill_cell = if (row.imported_only) + fmt.centerDash(&ill_cell_buf, view.table_cell_width) + else + view.fmtValueDeltaCell(&ill_cell_buf, row.illiquid, row.d_illiquid, view.table_cell_width); + const nw_cell = if (row.imported_only) + fmt.centerDash(&nw_cell_buf, view.table_cell_width) + else + view.fmtValueDeltaCell(&nw_cell_buf, row.net_worth, row.d_net_worth, view.table_cell_width); const marker: []const u8 = if (selected) "* " else " "; + // Pad date_s to a consistent *display* width of 28 columns + // (wide enough to fit the deepest drilldown label, which is + // ` ▶ W of YYYY-MM-DD` ~ 25 cols when fully drilled). + // Zig's `{s:10} {s} {s} {s}", - .{ marker, date_s, liq_cell, ill_cell, nw_cell }, + " {s}{s} {s} {s} {s}", + .{ marker, date_padded, liq_cell, ill_cell, nw_cell }, ); } fn rowStyle(th: theme.Theme, row: TableRow, metric: timeline.Metric) vaxis.Cell.Style { + if (row.tier_header != null) return th.mutedStyle(); const d_opt: ?f64 = switch (metric) { .liquid => row.d_liquid, .illiquid => row.d_illiquid, @@ -833,8 +1247,13 @@ pub fn renderCompareLines( // ── Header ── var then_buf: [10]u8 = undefined; var now_buf: [10]u8 = undefined; - const then_str = cv.then_date.format(&then_buf); - const now_str = compare_view.nowLabel(cv, &now_buf); + const then_iso = cv.then_date.format(&then_buf); + const now_iso = compare_view.nowLabel(cv, &now_buf); + + // Prefer bucketed labels (e.g. "Q1 2025 (ended 2025-03-28)") + // when the caller supplied them; fall back to plain ISO dates. + const then_str = cv.then_label orelse then_iso; + const now_str = cv.now_label orelse now_iso; const header = try std.fmt.allocPrint( arena, @@ -1285,3 +1704,69 @@ test "renderCompareLines: no hidden line when no add/remove" { test { _ = snapshot; } + +test "renderCompareLines: bucket labels override ISO dates in header" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const th = theme.default_theme; + + const cv = compare_view.CompareView{ + .then_date = Date.fromYmd(2025, 3, 28), + .now_date = Date.fromYmd(2026, 5, 8), + .days_between = 406, + .now_is_live = false, + .liquid = compare_view.buildTotalsRow(6_000_000, 8_000_000), + .symbols = &.{}, + .held_count = 0, + .added_count = 0, + .removed_count = 0, + .then_label = "Q1 2025 (ended 2025-03-28)", + .now_label = null, + }; + const lines = try renderCompareLines(a, th, cv); + + var found_header = false; + for (lines) |l| { + if (std.mem.indexOf(u8, l.text, "Q1 2025 (ended 2025-03-28)") != null and + std.mem.indexOf(u8, l.text, "→ 2026-05-08") != null) + { + found_header = true; + break; + } + } + try testing.expect(found_header); +} + +test "priorPointBefore: returns null for the very first data point" { + const points = [_]timeline.TimelinePoint{ + .{ + .as_of_date = Date.fromYmd(2014, 7, 3), + .net_worth = 1_280_000, + .liquid = 1_280_000, + .illiquid = 0, + .accounts = &.{}, + .tax_types = &.{}, + .source = .imported, + }, + .{ + .as_of_date = Date.fromYmd(2015, 7, 3), + .net_worth = 1_500_000, + .liquid = 1_500_000, + .illiquid = 0, + .accounts = &.{}, + .tax_types = &.{}, + .source = .imported, + }, + }; + // Target preceding all data → null. + try testing.expectEqual(@as(?timeline.TimelinePoint, null), priorPointBefore(&points, Date.fromYmd(2014, 1, 1))); + // Target after first → finds the first point. + const got = priorPointBefore(&points, Date.fromYmd(2015, 1, 1)); + try testing.expect(got != null); + try testing.expect(got.?.as_of_date.eql(Date.fromYmd(2014, 7, 3))); + // Target after both → finds the latest. + const got2 = priorPointBefore(&points, Date.fromYmd(2026, 1, 1)); + try testing.expect(got2 != null); + try testing.expect(got2.?.as_of_date.eql(Date.fromYmd(2015, 7, 3))); +} diff --git a/src/views/compare.zig b/src/views/compare.zig index bbbecea..0ef9b48 100644 --- a/src/views/compare.zig +++ b/src/views/compare.zig @@ -50,6 +50,7 @@ const std = @import("std"); const fmt = @import("../format.zig"); const Date = @import("../models/date.zig").Date; +const timeline = @import("../analytics/timeline.zig"); const view_hist = @import("history.zig"); pub const StyleIntent = fmt.StyleIntent; @@ -150,11 +151,67 @@ pub const CompareView = struct { /// is available; always null in unit-tested / TUI flows. attribution: ?Attribution = null, + /// Optional human-facing labels for each side. When set, the + /// renderer uses these instead of the bare `YYYY-MM-DD` for the + /// header — useful for bucketed selections where the user picked + /// (e.g.) "Q1 2025" and we want to render + /// "Q1 2025 (ended 2025-03-28)" rather than "2025-03-28" alone. + /// Null falls back to ISO-date rendering. + then_label: ?[]const u8 = null, + now_label: ?[]const u8 = null, + pub fn deinit(self: *CompareView, allocator: std.mem.Allocator) void { allocator.free(self.symbols); + if (self.then_label) |l| allocator.free(l); + if (self.now_label) |l| allocator.free(l); } }; +/// Build a human-facing label for a History-tab row selected as +/// the "then" or "now" side of a compare view. Returns an +/// allocator-owned string like `"Q1 2025 (ended 2025-03-28)"`, +/// or null when the caller should fall back to plain ISO-date +/// rendering. Null is returned for: +/// - Live rows (the "now" side often points at the in-progress +/// bucket; caller renders this as `"today"` itself). +/// - Daily rows or rows without a tier (the ISO date is already +/// the right label — annotating `"2025-03-28 (ended 2025-03-28)"` +/// would be useless duplication). +/// +/// The label is built by composing `timeline.formatBucketLabel` +/// with an ` (ended YYYY-MM-DD)` suffix, so this function shares +/// all tier-formatting logic with the cascading-history table. +/// +/// `tier`/`bucket_start` are optional to match the upstream +/// `TableRow`/CLI bucket-row shapes, where rows without a bucket +/// origin (live rows, plain daily rows) carry null. When +/// `bucket_start` is null but a tier is present the function +/// returns the ISO date alone (defensive — shouldn't happen for +/// non-daily rows, but keeps the renderer honest if it does). +pub fn buildBucketLabel( + allocator: std.mem.Allocator, + tier: ?timeline.Tier, + bucket_start: ?Date, + date: Date, + is_live: bool, +) !?[]const u8 { + if (is_live) return null; + const t = tier orelse return null; + if (t == .daily) return null; + + var iso_buf: [10]u8 = undefined; + const iso = date.format(&iso_buf); + + // No bucket origin → return ISO alone (caller may have wanted + // a label for some surface-specific reason; better than null + // here because we already committed to "non-daily" above). + const start = bucket_start orelse return try allocator.dupe(u8, iso); + + var prefix_buf: [32]u8 = undefined; + const prefix = timeline.formatBucketLabel(&prefix_buf, t, start); + return try std.fmt.allocPrint(allocator, "{s} (ended {s})", .{ prefix, iso }); +} + /// Threshold under which a pct_change is considered flat for the /// gainer/loser summary footer. `0.0001 == 0.01%`. Chosen so penny- /// level rounding noise on high-priced positions (e.g. a $500 stock @@ -315,6 +372,80 @@ pub fn buildCompareView( const testing = std.testing; +test "buildBucketLabel: live and daily rows return null" { + // Live row. + const got_live = try buildBucketLabel(testing.allocator, null, null, Date.fromYmd(2026, 5, 11), true); + try testing.expectEqual(@as(?[]const u8, null), got_live); + + // Daily-tier row. + const got_daily = try buildBucketLabel(testing.allocator, .daily, null, Date.fromYmd(2026, 5, 8), false); + try testing.expectEqual(@as(?[]const u8, null), got_daily); + + // No tier at all. + const got_no_tier = try buildBucketLabel(testing.allocator, null, null, Date.fromYmd(2026, 5, 8), false); + try testing.expectEqual(@as(?[]const u8, null), got_no_tier); +} + +test "buildBucketLabel: quarterly row produces 'Qn YYYY (ended ...)' label" { + const lbl = (try buildBucketLabel( + testing.allocator, + .quarterly, + Date.fromYmd(2025, 1, 1), + Date.fromYmd(2025, 3, 28), + false, + )).?; + defer testing.allocator.free(lbl); + try testing.expectEqualStrings("Q1 2025 (ended 2025-03-28)", lbl); +} + +test "buildBucketLabel: yearly row produces 'YYYY (ended ...)' label" { + const lbl = (try buildBucketLabel( + testing.allocator, + .yearly, + Date.fromYmd(2024, 1, 1), + Date.fromYmd(2024, 12, 31), + false, + )).?; + defer testing.allocator.free(lbl); + try testing.expectEqualStrings("2024 (ended 2024-12-31)", lbl); +} + +test "buildBucketLabel: monthly row produces 'Mmm YYYY (ended ...)' label" { + const lbl = (try buildBucketLabel( + testing.allocator, + .monthly, + Date.fromYmd(2026, 1, 1), + Date.fromYmd(2026, 1, 30), + false, + )).?; + defer testing.allocator.free(lbl); + try testing.expectEqualStrings("Jan 2026 (ended 2026-01-30)", lbl); +} + +test "buildBucketLabel: weekly row produces 'W of YYYY-MM-DD (ended ...)' label" { + const lbl = (try buildBucketLabel( + testing.allocator, + .weekly, + Date.fromYmd(2026, 4, 20), + Date.fromYmd(2026, 4, 22), + false, + )).?; + defer testing.allocator.free(lbl); + try testing.expectEqualStrings("W of 2026-04-20 (ended 2026-04-22)", lbl); +} + +test "buildBucketLabel: missing bucket_start on non-daily tier falls back to ISO" { + const lbl = (try buildBucketLabel( + testing.allocator, + .quarterly, + null, + Date.fromYmd(2025, 3, 28), + false, + )).?; + defer testing.allocator.free(lbl); + try testing.expectEqualStrings("2025-03-28", lbl); +} + test "buildSymbolChange: positive price move, shares stable" { const c = buildSymbolChange("AAPL", 100, 150.0, 100, 165.0); try testing.expectEqualStrings("AAPL", c.symbol); diff --git a/src/views/history.zig b/src/views/history.zig index d8ab020..215027b 100644 --- a/src/views/history.zig +++ b/src/views/history.zig @@ -35,14 +35,29 @@ 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 annualized % (CAGR) column. Same shape as +/// `windows_pct_width`. Right-aligned. +pub const windows_ann_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; +/// Width of each `"$value (±Δ)"` composite cell in the table. +/// Composed of two sub-columns: +/// - value: right-aligned dollar amount (`value_subcol_width`) +/// - delta: left-aligned signed delta (`delta_subcol_width`) +/// separated by a single space. Both sub-cells are padded +/// independently so leading-digit columns line up vertically +/// across rows of varying magnitude. +/// +/// Worst case sizes: +/// value: `$99,999,999.99` = 14 chars +/// delta: `(+$9,999,999.99)` = 16 chars +/// Composite total = 14 + 1 + 16 = 31 chars. +pub const value_subcol_width: usize = 14; +pub const delta_subcol_width: usize = 16; +pub const table_cell_width: usize = value_subcol_width + 1 + delta_subcol_width; // ── Windows block row ──────────────────────────────────────── @@ -58,24 +73,30 @@ pub const WindowRowCells = struct { label: []const u8, delta_str: []const u8, pct_str: []const u8, + /// Annualized (CAGR) percentage, formatted with sign and `%`. + /// Same `"n/a"` fallback as `pct_str` when input is null — + /// missing anchor or non-finite math. + ann_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. +/// Render a WindowStat into displayable cells. `delta_buf`, +/// `pct_buf`, and `ann_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 +/// The pct columns never show 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`. +/// is the caller's job via `windows_pct_width` / `windows_ann_width`. pub fn buildWindowRowCells( row: timeline.WindowStat, delta_buf: *[32]u8, pct_buf: *[16]u8, + ann_buf: *[16]u8, ) WindowRowCells { const style: StyleIntent = blk: { const d = row.delta_abs orelse break :blk .muted; @@ -96,10 +117,16 @@ pub fn buildWindowRowCells( // Both read as "pct undefined" for the user. "n/a"; + const ann_str: []const u8 = if (row.annualized_pct) |a| + fmtSignedPercentBuf(ann_buf, a) + else + "—"; + return .{ .label = row.label, .delta_str = delta_str, .pct_str = pct_str, + .ann_str = ann_str, .style = style, }; } @@ -131,16 +158,25 @@ pub fn fmtSignedPercentBuf(buf: *[16]u8, ratio: f64) []const u8 { // ── 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. +/// table. /// -/// `delta_opt = null` (first row) renders as `"$value (—)"` — the -/// em-dash signals "no prior row to compare against" without wasting -/// a Δ column. +/// Two sub-columns aligned independently: +/// - value: right-aligned in `value_subcol_width` chars so digits +/// stack cleanly across rows of differing magnitude. +/// - delta: `(±$D.DD)` left-aligned in `delta_subcol_width` chars +/// so the closing `)` lands at a consistent column. +/// Separated by a single space. /// -/// 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. +/// `delta_opt = null` (first row) renders as `(—)` — the em-dash +/// signals "no prior row to compare against" without wasting a Δ +/// column. +/// +/// `width` parameter is honored but the value/delta sub-widths are +/// fixed at the module-level constants. If `width > +/// table_cell_width`, additional left-padding is added (so the +/// caller's column is wider but the sub-columns stay aligned). +/// If `width < table_cell_width`, sub-columns may overflow the +/// caller's slot — caller's problem; defaults match. pub fn fmtValueDeltaCell( buf: []u8, value: f64, @@ -148,36 +184,63 @@ pub fn fmtValueDeltaCell( width: usize, ) []const u8 { var val_buf: [24]u8 = undefined; - var delta_buf: [32]u8 = undefined; + var delta_inner: [32]u8 = undefined; + var delta_outer: [40]u8 = undefined; const val_str = fmt.fmtMoneyAbs(&val_buf, value); - const d_str: []const u8 = if (delta_opt) |d| - fmtSignedMoneyBuf(&delta_buf, d) + const d_inner: []const u8 = if (delta_opt) |d| + fmtSignedMoneyBuf(&delta_inner, d) else "—"; + const d_str = std.fmt.bufPrint(&delta_outer, "({s})", .{d_inner}) catch return "?"; - // 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 "?"; + // Build the composite manually so the sub-column widths are + // honored exactly. value is right-aligned, delta is left-aligned. + var inner_buf: [96]u8 = undefined; + var pos: usize = 0; - 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]); + // Value sub-column: left-pad with spaces if shorter. + if (val_str.len < value_subcol_width) { + const lpad = value_subcol_width - val_str.len; + @memset(inner_buf[pos .. pos + lpad], ' '); + pos += lpad; + } + @memcpy(inner_buf[pos .. pos + val_str.len], val_str); + pos += val_str.len; + + // Separator. + inner_buf[pos] = ' '; + pos += 1; + + // Delta sub-column: append, then right-pad with spaces. + // Pad based on display width — `(—)` is 5 bytes / 3 display cols. + @memcpy(inner_buf[pos .. pos + d_str.len], d_str); + pos += d_str.len; + const d_display = fmt.displayCols(d_str); + if (d_display < delta_subcol_width) { + const rpad = delta_subcol_width - d_display; + @memset(inner_buf[pos .. pos + rpad], ' '); + pos += rpad; + } + + const inner = inner_buf[0..pos]; + + if (inner.len >= width) { + // Composite is at or beyond the caller's slot; emit as-is. + const n = @min(inner.len, buf.len); + @memcpy(buf[0..n], inner[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]); + // Caller wants a wider slot; left-pad. + const pad = width - inner.len; + if (pad + inner.len > buf.len) { + const n = @min(inner.len, buf.len); + @memcpy(buf[0..n], inner[0..n]); return buf[0..n]; } @memset(buf[0..pad], ' '); - @memcpy(buf[pad .. pad + natural.len], natural); - return buf[0 .. pad + natural.len]; + @memcpy(buf[pad .. pad + inner.len], inner); + return buf[0 .. pad + inner.len]; } // ── Resolution label ───────────────────────────────────────── @@ -226,6 +289,12 @@ fn makeWindowStat( .end_value = end_value, .delta_abs = delta_abs, .delta_pct = delta_pct, + // Tests in this file don't exercise the annualized math — + // that's covered by `computeWindowSet` tests in + // analytics/timeline.zig. Pass the same value as + // `delta_pct` so the renderer-side formatting path is + // exercised. + .annualized_pct = delta_pct, }; } @@ -234,19 +303,22 @@ fn makeWindowStat( test "buildWindowRowCells: positive delta" { var db: [32]u8 = undefined; var pb: [16]u8 = undefined; + var ab: [16]u8 = undefined; const row = makeWindowStat(null, "1 day", 1000, 1100); - const cells = buildWindowRowCells(row, &db, &pb); + const cells = buildWindowRowCells(row, &db, &pb, &ab); 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.expectEqualStrings("+10.00%", cells.ann_str); try testing.expectEqual(StyleIntent.positive, cells.style); } test "buildWindowRowCells: negative delta" { var db: [32]u8 = undefined; var pb: [16]u8 = undefined; + var ab: [16]u8 = undefined; const row = makeWindowStat(null, "1 day", 1000, 900); - const cells = buildWindowRowCells(row, &db, &pb); + const cells = buildWindowRowCells(row, &db, &pb, &ab); try testing.expectEqualStrings("-$100.00", cells.delta_str); try testing.expectEqualStrings("-10.00%", cells.pct_str); try testing.expectEqual(StyleIntent.negative, cells.style); @@ -255,8 +327,9 @@ test "buildWindowRowCells: negative delta" { test "buildWindowRowCells: zero delta renders muted" { var db: [32]u8 = undefined; var pb: [16]u8 = undefined; + var ab: [16]u8 = undefined; const row = makeWindowStat(null, "1 day", 1000, 1000); - const cells = buildWindowRowCells(row, &db, &pb); + const cells = buildWindowRowCells(row, &db, &pb, &ab); try testing.expectEqualStrings("$0.00", cells.delta_str); try testing.expectEqualStrings("0.00%", cells.pct_str); try testing.expectEqual(StyleIntent.muted, cells.style); @@ -265,18 +338,21 @@ test "buildWindowRowCells: zero delta renders muted" { test "buildWindowRowCells: missing anchor renders muted with n/a strings" { var db: [32]u8 = undefined; var pb: [16]u8 = undefined; + var ab: [16]u8 = undefined; const row = makeWindowStat(null, "1 year", null, 1100); - const cells = buildWindowRowCells(row, &db, &pb); + const cells = buildWindowRowCells(row, &db, &pb, &ab); try testing.expectEqualStrings("n/a", cells.delta_str); try testing.expectEqualStrings("n/a", cells.pct_str); + try testing.expectEqualStrings("—", cells.ann_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; + var ab: [16]u8 = undefined; const row = makeWindowStat(null, "All-time", 0, 100); - const cells = buildWindowRowCells(row, &db, &pb); + const cells = buildWindowRowCells(row, &db, &pb, &ab); // 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); @@ -305,36 +381,66 @@ test "fmtSignedPercentBuf: signs + zero + flush digits" { // ── fmtValueDeltaCell ── -test "fmtValueDeltaCell: padded to width, positive delta" { +test "fmtValueDeltaCell: sub-columns produce expected layout" { 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]); + const s = fmtValueDeltaCell(&buf, 1000, 50, table_cell_width); + // Composite total = 14 + 1 + 16 = 31 chars. + try testing.expectEqual(table_cell_width, s.len); + + // Value sub-column: right-aligned in 14 chars. + // "$1,000.00" = 9 chars → 5 leading spaces. + try testing.expectEqualStrings(" $1,000.00", s[0..value_subcol_width]); + + // Separator space. + try testing.expectEqual(@as(u8, ' '), s[value_subcol_width]); + + // Delta sub-column: left-aligned in 16 chars. + // "(+$50.00)" = 9 chars → 7 trailing spaces. + const delta_part = s[value_subcol_width + 1 ..]; + try testing.expect(std.mem.startsWith(u8, delta_part, "(+$50.00)")); + try testing.expectEqual(delta_subcol_width, delta_part.len); } 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 (—)")); + const s = fmtValueDeltaCell(&buf, 1000, null, table_cell_width); + // Em-dash bytes: 3 bytes / 1 display col. Cell display width + // is `table_cell_width` (31), but byte length is 33. + try testing.expectEqual(@as(usize, table_cell_width + 2), s.len); + // Delta sub-column starts with `(—)`. + const delta_part = s[value_subcol_width + 1 ..]; + try testing.expect(std.mem.startsWith(u8, delta_part, "(—)")); } 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)")); + const s = fmtValueDeltaCell(&buf, 1000, -50, table_cell_width); + const delta_part = s[value_subcol_width + 1 ..]; + try testing.expect(std.mem.startsWith(u8, delta_part, "(-$50.00)")); } -test "fmtValueDeltaCell: overflow returns un-truncated natural cell" { - // 10-char width is too small for "$1,000.00 (+$50.00)" (19 chars). +test "fmtValueDeltaCell: large value and delta still align" { + // Both values right-aligned in their sub-columns; closing + // parens line up on the right of the cell. + var buf_a: [64]u8 = undefined; + const a = fmtValueDeltaCell(&buf_a, 8_633_578.46, 86_757.10, table_cell_width); + var buf_b: [64]u8 = undefined; + const b = fmtValueDeltaCell(&buf_b, 6_614_620.99, 1_340_130.92, table_cell_width); + + // Both should be exactly table_cell_width chars long. + try testing.expectEqual(table_cell_width, a.len); + try testing.expectEqual(table_cell_width, b.len); + + // Both should end at column `value_subcol_width + 1 + + // delta_subcol_width` — which by construction is true since + // both have length == table_cell_width. +} + +test "fmtValueDeltaCell: caller width smaller than composite still emits full content" { + // Caller asked for 10 cols; composite needs 31. Emit composite as-is. 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")); + try testing.expect(s.len >= 31); } // ── fmtResolutionLabel ──