|
| 1 | +/* |
| 2 | + * KB Staleness Badge |
| 3 | + * |
| 4 | + * Shows a colored badge in the active note's title bar based on the |
| 5 | + * `last-validated:` frontmatter field. Helps make spec/playbook staleness |
| 6 | + * visible at a glance without opening the stale-specs dashboard. |
| 7 | + * |
| 8 | + * Thresholds match what stale-specs.md, the daily-note morning brief, |
| 9 | + * and the release-qualification playbook use: |
| 10 | + * |
| 11 | + * < 30 days → green (fresh) |
| 12 | + * 30..89 → yellow (gentle nudge) |
| 13 | + * 90..179 → orange (stale-specs query flags it) |
| 14 | + * >= 180 → red (release blocker per release-qualification) |
| 15 | + * |
| 16 | + * Notes without `last-validated:` get no badge. Notes whose value doesn't |
| 17 | + * parse as YYYY-MM-DD also get no badge — the plugin is best-effort, not |
| 18 | + * a contract. |
| 19 | + */ |
| 20 | + |
| 21 | +import { MarkdownView, Plugin } from "obsidian"; |
| 22 | + |
| 23 | +interface StalenessSettings { |
| 24 | + yellowDays: number; |
| 25 | + orangeDays: number; |
| 26 | + redDays: number; |
| 27 | +} |
| 28 | + |
| 29 | +const DEFAULT_SETTINGS: StalenessSettings = { |
| 30 | + yellowDays: 30, |
| 31 | + orangeDays: 90, |
| 32 | + redDays: 180, |
| 33 | +}; |
| 34 | + |
| 35 | +const COLORS = { |
| 36 | + green: "#2f855a", |
| 37 | + yellow: "#d99124", |
| 38 | + orange: "#dd6b20", |
| 39 | + red: "#d15451", |
| 40 | +}; |
| 41 | + |
| 42 | +const BADGE_CLASS = "kb-staleness-badge"; |
| 43 | + |
| 44 | +export default class StalenessBadge extends Plugin { |
| 45 | + settings!: StalenessSettings; |
| 46 | + |
| 47 | + async onload() { |
| 48 | + await this.loadSettings(); |
| 49 | + |
| 50 | + this.registerEvent( |
| 51 | + this.app.workspace.on("active-leaf-change", () => this.refresh()) |
| 52 | + ); |
| 53 | + |
| 54 | + this.registerEvent( |
| 55 | + this.app.metadataCache.on("changed", (file) => { |
| 56 | + const active = this.app.workspace.getActiveFile(); |
| 57 | + if (active && active.path === file.path) this.refresh(); |
| 58 | + }) |
| 59 | + ); |
| 60 | + |
| 61 | + this.app.workspace.onLayoutReady(() => this.refresh()); |
| 62 | + } |
| 63 | + |
| 64 | + onunload() { |
| 65 | + this.removeBadges(); |
| 66 | + } |
| 67 | + |
| 68 | + private refresh() { |
| 69 | + this.removeBadges(); |
| 70 | + |
| 71 | + const view = this.app.workspace.getActiveViewOfType(MarkdownView); |
| 72 | + if (!view || !view.file) return; |
| 73 | + |
| 74 | + const cache = this.app.metadataCache.getFileCache(view.file); |
| 75 | + const lastValidated = cache?.frontmatter?.["last-validated"]; |
| 76 | + if (!lastValidated) return; |
| 77 | + |
| 78 | + const date = parseDate(lastValidated); |
| 79 | + if (!date) return; |
| 80 | + |
| 81 | + const ageDays = Math.floor((Date.now() - date.getTime()) / 86_400_000); |
| 82 | + const color = this.colorFor(ageDays); |
| 83 | + this.renderBadge(view, ageDays, color); |
| 84 | + } |
| 85 | + |
| 86 | + private colorFor(ageDays: number): string { |
| 87 | + if (ageDays >= this.settings.redDays) return COLORS.red; |
| 88 | + if (ageDays >= this.settings.orangeDays) return COLORS.orange; |
| 89 | + if (ageDays >= this.settings.yellowDays) return COLORS.yellow; |
| 90 | + return COLORS.green; |
| 91 | + } |
| 92 | + |
| 93 | + private renderBadge(view: MarkdownView, ageDays: number, color: string) { |
| 94 | + const container = view.containerEl.querySelector( |
| 95 | + ".view-header-title-container" |
| 96 | + ) as HTMLElement | null; |
| 97 | + if (!container) return; |
| 98 | + |
| 99 | + const badge = document.createElement("span"); |
| 100 | + badge.classList.add(BADGE_CLASS); |
| 101 | + badge.textContent = `${ageDays}d`; |
| 102 | + badge.title = |
| 103 | + `Validated ${ageDays} day${ageDays === 1 ? "" : "s"} ago. ` + |
| 104 | + `Yellow at ${this.settings.yellowDays}+, orange at ${this.settings.orangeDays}+, ` + |
| 105 | + `red at ${this.settings.redDays}+ (release blocker per release-qualification playbook).`; |
| 106 | + |
| 107 | + Object.assign(badge.style, { |
| 108 | + marginLeft: "8px", |
| 109 | + padding: "2px 6px", |
| 110 | + borderRadius: "3px", |
| 111 | + fontSize: "0.75em", |
| 112 | + fontWeight: "600", |
| 113 | + fontFamily: "var(--font-monospace)", |
| 114 | + color: "white", |
| 115 | + background: color, |
| 116 | + cursor: "help", |
| 117 | + }); |
| 118 | + |
| 119 | + container.appendChild(badge); |
| 120 | + } |
| 121 | + |
| 122 | + private removeBadges() { |
| 123 | + document.querySelectorAll(`.${BADGE_CLASS}`).forEach((e) => e.remove()); |
| 124 | + } |
| 125 | + |
| 126 | + async loadSettings() { |
| 127 | + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); |
| 128 | + } |
| 129 | +} |
| 130 | + |
| 131 | +function parseDate(input: unknown): Date | null { |
| 132 | + if (!input) return null; |
| 133 | + if (input instanceof Date) return input; |
| 134 | + const s = String(input); |
| 135 | + const m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); |
| 136 | + if (!m) return null; |
| 137 | + const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])); |
| 138 | + return isNaN(d.getTime()) ? null : d; |
| 139 | +} |
0 commit comments