//! Staleness nags for hand-maintained data sources. //! //! Some datasets in this codebase require periodic human maintenance //! (e.g. appending the latest year's T-bill rate, replacing Shiller's //! `ie_data.csv`). Each such source declares a `pub const _last_updated: Date` //! constant which a human bumps when the refresh happens. //! //! This module registers those sources and prints a warning to stderr //! on every `zfin` invocation once the annual refresh window opens //! and the data is still stale. The refresh window is expressed as a //! single `(month, day)` per year — the earliest date by which fresh //! upstream data is expected to be available — not as a rolling //! day-count. //! //! ### Nagging semantics //! //! Given today's date and an entry's `(due_month, due_day, last_updated)`: //! //! 1. Compute `this_years_due = Date.fromYmd(today.year(), due_month, due_day)`. //! 2. If `today < this_years_due` — not yet nag season, silent. //! 3. If `last_updated >= this_years_due` — already refreshed this cycle, silent. //! 4. Otherwise — nag. //! //! The nag keeps firing every invocation until the human bumps //! `last_updated` past `this_years_due`. //! //! ### Adding a new source //! //! 1. Add `pub const _last_updated: Date = Date.fromYmd(YYYY, MM, DD);` //! to the module that owns the data. //! 2. Append a new `StaleEntry` to `entries` below. //! 3. Document the refresh procedure in the owning module's doc //! comment (or a `TODO` at the top). const std = @import("std"); const Date = @import("../Date.zig"); const risk = @import("../analytics/risk.zig"); const shiller = @import("shiller.zig"); const review = @import("../views/review.zig"); const observations = @import("../analytics/observations.zig"); /// A hand-maintained data source that nags once a year if it hasn't /// been refreshed by its annual `(due_month, due_day)`. pub const StaleEntry = struct { /// Human-readable name surfaced in the warning. name: []const u8, /// The last time a human refreshed this data. Bumped by hand. last_updated: Date, /// Calendar month (1-12) by which fresh upstream data is expected. due_month: u8, /// Calendar day (1-31) within `due_month`. due_day: u8, /// Source file to point the user at for refresh instructions. source_file: []const u8, }; /// Registry of hand-maintained data sources in this codebase. Passed /// to `check` from `main.zig`; tests construct their own slices. pub const entries = [_]StaleEntry{ .{ .name = "T-bill risk-free rate table", .last_updated = risk.tbill_rates_last_updated, .due_month = 1, .due_day = 31, .source_file = "src/analytics/risk.zig", }, .{ .name = "Shiller annual returns (ie_data.csv)", .last_updated = shiller.ie_data_last_updated, .due_month = 4, .due_day = 1, .source_file = "src/data/shiller.zig", }, .{ .name = "Review tab MaxDD color thresholds", .last_updated = review.maxdd_thresholds_last_reviewed, .due_month = 6, .due_day = 1, .source_file = "src/views/review.zig", }, .{ .name = "Observation engine thresholds", .last_updated = observations.observation_thresholds_last_reviewed, .due_month = 6, .due_day = 1, .source_file = "src/analytics/observations.zig", }, }; /// Per-entry refresh verdict as of `as_of`. pub const Status = enum { ok, overdue }; /// Verdict for a single entry as of `as_of`, applying the same window /// logic as `check` (see the module doc-block). Exposed so callers /// that want an explicit per-entry OK/overdue line (e.g. `zfin doctor`) /// can render every entry, not just the overdue ones that `check` /// emits. pub fn entryStatus(entry: StaleEntry, as_of: Date) Status { const this_years_due = Date.fromYmd(as_of.year(), entry.due_month, entry.due_day); // Not yet nag season. if (as_of.lessThan(this_years_due)) return .ok; // Already refreshed this cycle. if (!entry.last_updated.lessThan(this_years_due)) return .ok; return .overdue; } /// Write a warning line for each entry in `entries` that is overdue /// for refresh as of `as_of`. Writes nothing when every entry is /// fresh. Entries are processed in order; multiple warnings are /// separated by a blank line. pub fn check( writer: *std.Io.Writer, as_of: Date, list: []const StaleEntry, ) !void { var wrote_any = false; for (list) |entry| { if (entryStatus(entry, as_of) == .ok) continue; const this_years_due = Date.fromYmd( as_of.year(), entry.due_month, entry.due_day, ); if (wrote_any) try writer.writeAll("\n"); wrote_any = true; try writer.print( "warning: {s} is overdue for refresh.\n" ++ " Expected by {f}; last updated {f}.\n" ++ " See {s} for refresh instructions.\n", .{ entry.name, this_years_due, entry.last_updated, entry.source_file }, ); } } // ── Tests ──────────────────────────────────────────────────── test "silent before due date" { var buf: [1024]u8 = undefined; var w = std.Io.Writer.fixed(&buf); const list = [_]StaleEntry{.{ .name = "Test data", .last_updated = Date.fromYmd(2025, 3, 15), .due_month = 4, .due_day = 1, .source_file = "src/test.zig", }}; try check(&w, Date.fromYmd(2026, 3, 31), &list); try std.testing.expectEqualStrings("", w.buffered()); } test "nag on due date when stale" { var buf: [1024]u8 = undefined; var w = std.Io.Writer.fixed(&buf); const list = [_]StaleEntry{.{ .name = "Test data", .last_updated = Date.fromYmd(2025, 3, 15), .due_month = 4, .due_day = 1, .source_file = "src/test.zig", }}; try check(&w, Date.fromYmd(2026, 4, 1), &list); const expected = "warning: Test data is overdue for refresh.\n" ++ " Expected by 2026-04-01; last updated 2025-03-15.\n" ++ " See src/test.zig for refresh instructions.\n"; try std.testing.expectEqualStrings(expected, w.buffered()); } test "nag after due date when stale" { var buf: [1024]u8 = undefined; var w = std.Io.Writer.fixed(&buf); const list = [_]StaleEntry{.{ .name = "Test data", .last_updated = Date.fromYmd(2025, 3, 15), .due_month = 4, .due_day = 1, .source_file = "src/test.zig", }}; try check(&w, Date.fromYmd(2026, 6, 15), &list); const expected = "warning: Test data is overdue for refresh.\n" ++ " Expected by 2026-04-01; last updated 2025-03-15.\n" ++ " See src/test.zig for refresh instructions.\n"; try std.testing.expectEqualStrings(expected, w.buffered()); } test "silent when already refreshed this cycle" { var buf: [1024]u8 = undefined; var w = std.Io.Writer.fixed(&buf); const list = [_]StaleEntry{.{ .name = "Test data", .last_updated = Date.fromYmd(2026, 4, 2), .due_month = 4, .due_day = 1, .source_file = "src/test.zig", }}; try check(&w, Date.fromYmd(2026, 6, 15), &list); try std.testing.expectEqualStrings("", w.buffered()); } test "silent when last_updated equals this year's due date" { var buf: [1024]u8 = undefined; var w = std.Io.Writer.fixed(&buf); const list = [_]StaleEntry{.{ .name = "Test data", .last_updated = Date.fromYmd(2026, 4, 1), .due_month = 4, .due_day = 1, .source_file = "src/test.zig", }}; try check(&w, Date.fromYmd(2026, 4, 1), &list); try std.testing.expectEqualStrings("", w.buffered()); } test "two-cycle stale still fires" { var buf: [1024]u8 = undefined; var w = std.Io.Writer.fixed(&buf); const list = [_]StaleEntry{.{ .name = "Test data", .last_updated = Date.fromYmd(2024, 2, 1), .due_month = 4, .due_day = 1, .source_file = "src/test.zig", }}; try check(&w, Date.fromYmd(2026, 6, 15), &list); const expected = "warning: Test data is overdue for refresh.\n" ++ " Expected by 2026-04-01; last updated 2024-02-01.\n" ++ " See src/test.zig for refresh instructions.\n"; try std.testing.expectEqualStrings(expected, w.buffered()); } test "empty list is a no-op" { var buf: [16]u8 = undefined; var w = std.Io.Writer.fixed(&buf); try check(&w, Date.fromYmd(2030, 12, 31), &[_]StaleEntry{}); try std.testing.expectEqualStrings("", w.buffered()); } test "multiple stale entries separated by blank line" { var buf: [2048]u8 = undefined; var w = std.Io.Writer.fixed(&buf); const list = [_]StaleEntry{ .{ .name = "Alpha", .last_updated = Date.fromYmd(2024, 12, 1), .due_month = 1, .due_day = 31, .source_file = "src/alpha.zig", }, .{ .name = "Beta", .last_updated = Date.fromYmd(2025, 3, 15), .due_month = 4, .due_day = 1, .source_file = "src/beta.zig", }, }; try check(&w, Date.fromYmd(2026, 6, 15), &list); const expected = "warning: Alpha is overdue for refresh.\n" ++ " Expected by 2026-01-31; last updated 2024-12-01.\n" ++ " See src/alpha.zig for refresh instructions.\n" ++ "\n" ++ "warning: Beta is overdue for refresh.\n" ++ " Expected by 2026-04-01; last updated 2025-03-15.\n" ++ " See src/beta.zig for refresh instructions.\n"; try std.testing.expectEqualStrings(expected, w.buffered()); } test "fresh entry does not emit blank line before later stale entry" { var buf: [2048]u8 = undefined; var w = std.Io.Writer.fixed(&buf); const list = [_]StaleEntry{ .{ .name = "Alpha", .last_updated = Date.fromYmd(2026, 2, 1), // fresh .due_month = 1, .due_day = 31, .source_file = "src/alpha.zig", }, .{ .name = "Beta", .last_updated = Date.fromYmd(2025, 3, 15), // stale .due_month = 4, .due_day = 1, .source_file = "src/beta.zig", }, }; try check(&w, Date.fromYmd(2026, 6, 15), &list); const expected = "warning: Beta is overdue for refresh.\n" ++ " Expected by 2026-04-01; last updated 2025-03-15.\n" ++ " See src/beta.zig for refresh instructions.\n"; try std.testing.expectEqualStrings(expected, w.buffered()); } test "silent when today is one day before due" { var buf: [256]u8 = undefined; var w = std.Io.Writer.fixed(&buf); const list = [_]StaleEntry{.{ .name = "Test data", .last_updated = Date.fromYmd(2020, 1, 1), .due_month = 4, .due_day = 1, .source_file = "src/test.zig", }}; try check(&w, Date.fromYmd(2026, 3, 31), &list); try std.testing.expectEqualStrings("", w.buffered()); } test "real registry compiles and is non-empty" { // Guard that the registry stays wired up; doesn't assert any // particular nag behavior (real dates drift over time). try std.testing.expect(entries.len >= 4); for (entries) |e| { try std.testing.expect(e.name.len > 0); try std.testing.expect(e.source_file.len > 0); try std.testing.expect(e.due_month >= 1 and e.due_month <= 12); try std.testing.expect(e.due_day >= 1 and e.due_day <= 31); } } test "entryStatus: ok before due date" { const e: StaleEntry = .{ .name = "X", .last_updated = Date.fromYmd(2020, 1, 1), .due_month = 4, .due_day = 1, .source_file = "x" }; try std.testing.expectEqual(Status.ok, entryStatus(e, Date.fromYmd(2026, 3, 31))); } test "entryStatus: overdue on/after due date when stale" { const e: StaleEntry = .{ .name = "X", .last_updated = Date.fromYmd(2025, 3, 15), .due_month = 4, .due_day = 1, .source_file = "x" }; try std.testing.expectEqual(Status.overdue, entryStatus(e, Date.fromYmd(2026, 4, 1))); try std.testing.expectEqual(Status.overdue, entryStatus(e, Date.fromYmd(2026, 6, 15))); } test "entryStatus: ok when refreshed this cycle" { const e: StaleEntry = .{ .name = "X", .last_updated = Date.fromYmd(2026, 4, 1), .due_month = 4, .due_day = 1, .source_file = "x" }; try std.testing.expectEqual(Status.ok, entryStatus(e, Date.fromYmd(2026, 6, 15))); }