diff --git a/src/commands/common.zig b/src/commands/common.zig index 2abb4f2..fecd248 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -34,6 +34,18 @@ pub fn setGainLoss(out: *std.Io.Writer, c: bool, value: f64) !void { } } +/// Map a semantic StyleIntent to CLI ANSI color. +pub fn setStyleIntent(out: *std.Io.Writer, c: bool, intent: fmt.StyleIntent) !void { + if (!c) return; + switch (intent) { + .normal => try reset(out, c), + .muted => try setFg(out, c, CLR_MUTED), + .positive => try setFg(out, c, CLR_POSITIVE), + .negative => try setFg(out, c, CLR_NEGATIVE), + .warning => try setFg(out, c, CLR_WARNING), + } +} + // ── Stderr helpers ─────────────────────────────────────────── pub fn stderrPrint(msg: []const u8) !void { diff --git a/src/commands/history.zig b/src/commands/history.zig index e781d00..dd0f505 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -346,9 +346,7 @@ fn renderWindowsBlock(out: *std.Io.Writer, color: bool, ws: timeline.WindowSet) switch (cells.style) { .positive => try cli.setFg(out, color, cli.CLR_POSITIVE), .negative => try cli.setFg(out, color, cli.CLR_NEGATIVE), - .muted => try cli.setFg(out, color, cli.CLR_MUTED), - // `normal` is unreachable in the windows block (build - // never emits it); no-op keeps the switch exhaustive. + .muted, .warning => try cli.setFg(out, color, cli.CLR_MUTED), .normal => {}, } diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index c42a18a..60f023a 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -6,13 +6,7 @@ const views = @import("../views/portfolio_sections.zig"); /// Map a semantic StyleIntent to CLI ANSI foreground color. fn setIntentFg(out: *std.Io.Writer, color: bool, intent: fmt.StyleIntent) !void { - if (!color) return; - switch (intent) { - .normal => try cli.reset(out, color), - .muted => try cli.setFg(out, color, cli.CLR_MUTED), - .positive => try cli.setFg(out, color, cli.CLR_POSITIVE), - .negative => try cli.setFg(out, color, cli.CLR_NEGATIVE), - } + try cli.setStyleIntent(out, color, intent); } pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void { diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 9d67cd8..951a430 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -203,8 +203,8 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, file_path: []co var note_buf: [128]u8 = undefined; if (view.fmtAllocationNote(¬e_buf, user_config.target_stock_pct, stock_pct)) |note| { try out.print("\n", .{}); - try cli.setFg(out, color, cli.CLR_MUTED); - try out.print("{s}\n", .{note}); + try cli.setStyleIntent(out, color, note.style); + try out.print("{s}\n", .{note.text}); try cli.reset(out, color); } } @@ -269,7 +269,7 @@ fn writeReturnRow(out: *std.Io.Writer, color: bool, row: view.ReturnRow) !void { } fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usize) !void { - try setIntentFg(out, color, cell.style); + try cli.setStyleIntent(out, color, cell.style); switch (width) { 8 => try out.print("{s: >8}", .{cell.text}), 9 => try out.print("{s: >9}", .{cell.text}), @@ -279,15 +279,6 @@ fn writeCell(out: *std.Io.Writer, color: bool, cell: view.ReturnCell, width: usi try cli.reset(out, color); } -fn setIntentFg(out: *std.Io.Writer, color: bool, intent: view.StyleIntent) !void { - switch (intent) { - .normal => try cli.reset(out, color), - .muted => try cli.setFg(out, color, cli.CLR_MUTED), - .positive => try cli.setFg(out, color, cli.CLR_POSITIVE), - .negative => try cli.setFg(out, color, cli.CLR_NEGATIVE), - } -} - fn candleDate(c: zfin.Candle) zfin.Date { return c.date; } diff --git a/src/format.zig b/src/format.zig index d8cdd37..b82ef64 100644 --- a/src/format.zig +++ b/src/format.zig @@ -403,6 +403,7 @@ pub const StyleIntent = enum { muted, // dim/secondary (expired items) positive, // green (gains, premium received) negative, // red (losses, premium paid) + warning, // yellow (stale data, drift) }; /// Summary of DRIP (dividend reinvestment) lots for a single ST or LT bucket. diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index d3b1c6e..f3cd748 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -246,15 +246,7 @@ fn appendWindowsBlock( " {s:<12} {s:>18} {s:>10}", .{ cells.label, cells.delta_str, cells.pct_str }, ); - const style: vaxis.Cell.Style = switch (cells.style) { - .positive => th.positiveStyle(), - .negative => th.negativeStyle(), - .muted => th.mutedStyle(), - // `normal` is unreachable in the windows block (build - // never emits it); fall back to content style to keep - // the switch exhaustive. - .normal => th.contentStyle(), - }; + const style: vaxis.Cell.Style = th.styleFor(cells.style); try lines.append(arena, .{ .text = text, .style = style }); } } diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index c8c868c..e116ba9 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -35,12 +35,7 @@ const gl_col_start: usize = col_end_market_value; /// Map a semantic StyleIntent to a platform-specific vaxis style. fn mapIntent(th: anytype, intent: fmt.StyleIntent) @import("vaxis").Style { - return switch (intent) { - .normal => th.contentStyle(), - .muted => th.mutedStyle(), - .positive => th.positiveStyle(), - .negative => th.negativeStyle(), - }; + return th.styleFor(intent); } // ── Data loading ────────────────────────────────────────────── diff --git a/src/tui/theme.zig b/src/tui/theme.zig index e6cc49c..7798319 100644 --- a/src/tui/theme.zig +++ b/src/tui/theme.zig @@ -1,6 +1,7 @@ const std = @import("std"); const vaxis = @import("vaxis"); const srf = @import("srf"); +const fmt = @import("../format.zig"); pub const Color = [3]u8; @@ -119,6 +120,17 @@ pub const Theme = struct { return .{ .fg = vcolor(self.warning), .bg = vcolor(self.bg) }; } + /// Map a semantic StyleIntent to a vaxis style. + pub fn styleFor(self: Theme, intent: fmt.StyleIntent) vaxis.Style { + return switch (intent) { + .normal => self.contentStyle(), + .muted => self.mutedStyle(), + .positive => self.positiveStyle(), + .negative => self.negativeStyle(), + .warning => self.warningStyle(), + }; + } + pub fn barFillStyle(self: Theme) vaxis.Style { return .{ .fg = vcolor(self.bar_fill), .bg = vcolor(self.bg) }; } diff --git a/src/views/projections.zig b/src/views/projections.zig index 7b9848c..92bcf5c 100644 --- a/src/views/projections.zig +++ b/src/views/projections.zig @@ -108,21 +108,41 @@ pub fn fmtHorizonLabel(buf: []u8, horizon: u16) []const u8 { // ── Allocation summary ───────────────────────────────────────── -/// Format the target allocation note line. +/// Result of formatting the allocation note. +pub const AllocationNote = struct { + text: []const u8, + style: StyleIntent, +}; + +/// Format the target allocation note line with drift-aware styling. /// Returns null if no target is configured. -pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f64) ?[]const u8 { +/// +/// Drift thresholds: +/// - Within 2%: "on target" (muted) +/// - 2–5% off: warning +/// - Over 5% off: negative +pub fn fmtAllocationNote(buf: []u8, target_stock_pct: ?f64, current_stock_pct: f64) ?AllocationNote { const target = target_stock_pct orelse return null; const current = current_stock_pct * 100; - const diff = current - target; + const drift = @abs(current - target); - if (@abs(diff) < 2.0) { - return std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}% \u{2014} on target)", .{ + const style: StyleIntent = if (drift < 2.0) + .muted + else if (drift < 5.0) + .warning + else + .negative; + + const text = if (drift < 2.0) + std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}% \u{2014} on target)", .{ target, 100.0 - target, current, - }) catch null; - } - return std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}%)", .{ - target, 100.0 - target, current, - }) catch null; + }) catch return null + else + std.fmt.bufPrint(buf, "Target allocation: {d:.0}% stocks / {d:.0}% bonds (current: {d:.1}%)", .{ + target, 100.0 - target, current, + }) catch return null; + + return .{ .text = text, .style = style }; } /// Format the stock benchmark label with weight (e.g. "SPY (83.8% weight)"). @@ -168,14 +188,23 @@ test "fmtAllocationNote on target" { var buf: [128]u8 = undefined; const note = fmtAllocationNote(&buf, 77, 0.768); try std.testing.expect(note != null); - try std.testing.expect(std.mem.indexOf(u8, note.?, "on target") != null); + try std.testing.expect(note.?.style == .muted); + try std.testing.expect(std.mem.indexOf(u8, note.?.text, "on target") != null); } test "fmtAllocationNote off target" { var buf: [128]u8 = undefined; const note = fmtAllocationNote(&buf, 77, 0.85); try std.testing.expect(note != null); - try std.testing.expect(std.mem.indexOf(u8, note.?, "on target") == null); + try std.testing.expect(note.?.style == .negative); // >5% drift + try std.testing.expect(std.mem.indexOf(u8, note.?.text, "on target") == null); +} + +test "fmtAllocationNote warning range" { + var buf: [128]u8 = undefined; + const note = fmtAllocationNote(&buf, 77, 0.80); + try std.testing.expect(note != null); + try std.testing.expect(note.?.style == .warning); // 3% drift, in 2-5% range } test "fmtAllocationNote no target" {