From cabacca071bf124eab154f3fdaac5f34f8657dc2 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Fri, 6 Nov 2026 20:07:17 +0530 Subject: [PATCH 1/5] feat(insights): add Markdown report builder Build a portable report from the compact dashboard with exact token counts and no rescan. Cover the report shape, cache scope, empty data, and table escaping. --- herdr-insights/mods/insights/analytics.js | 114 ++++++++++++++++++++++ tests/herdr-insights/analytics.test.mjs | 64 ++++++++++++ 2 files changed, 178 insertions(+) diff --git a/herdr-insights/mods/insights/analytics.js b/herdr-insights/mods/insights/analytics.js index 0e8dd3c..28a80ca 100644 --- a/herdr-insights/mods/insights/analytics.js +++ b/herdr-insights/mods/insights/analytics.js @@ -1160,6 +1160,120 @@ export function compactDashboard(derived) { }; } +/** Escapes one value for a Markdown table cell. */ +function markdownCell(value) { + return String(value ?? "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim(); +} + +function markdownDateTime(date) { + if (!(date instanceof Date) || Number.isNaN(date.getTime())) return ""; + return `${dayKey(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`; +} + +function markdownChange(entry) { + const change = entry?.change; + if (!change || change.kind === "none") return "—"; + if (change.kind === "new") return "New"; + const previous = Number(entry?.previous); + return Number.isFinite(previous) + ? `${change.label} (${formatInt(previous)} before)` + : String(change.label ?? "—"); +} + +/** + * A portable Markdown report of one prepared dashboard. Pure: it reads only the + * compact presentation already held by the view, so exporting never rescans, + * re-derives analytics, or runs the collector. Token counts stay exact; the + * dashboard's compact K/M/B labels are intentionally not reused. + */ +export function dashboardMarkdown(dashboard, options = {}) { + if (!dashboard || typeof dashboard !== "object") return ""; + const now = options.now instanceof Date ? options.now : new Date(options.now ?? Date.now()); + const generated = markdownDateTime(now); + const includeCache = dashboard.includeCache !== false; + const range = dashboard.range ?? {}; + const lifetime = dashboard.lifetime ?? {}; + const selectedTotals = dashboard.selectedTotals ?? {}; + const selectedTop = dashboard.selectedTop ?? null; + const leaders = dashboard.leaders ?? {}; + const heatmap = dashboard.heatmap ?? {}; + const filters = dashboard.filters ?? {}; + const rangeLabel = range.label ?? (dashboard.period === "all" ? "All time" : "Selected period"); + const filterLabel = (value) => (!value || value === "all" ? "All" : markdownCell(value)); + const dataThrough = dashboard.today ?? range.end ?? ""; + const selectedDay = isDay(dashboard.selectedDay) ? dashboard.selectedDay : dataThrough; + const lines = []; + + lines.push(`# Insights — ${rangeLabel}`, ""); + lines.push(`- Generated: ${generated || "unknown"} (local)`); + lines.push(`- Data through: ${dayLabel(dataThrough) || "unknown"}`); + lines.push(`- Period: ${rangeLabel}${range.start && range.end ? ` (${range.start} to ${range.end})` : ""}`); + lines.push(`- Filters: Provider: ${filterLabel(filters.provider)} · Model: ${filterLabel(filters.model)}`); + lines.push(`- Tokens: input + output${includeCache ? " + cache reads + cache writes" : " (cache excluded)"}`); + lines.push("", "Lifetime and token history cover every session. Model usage follows the selected period and filters.", ""); + + lines.push("## Lifetime", ""); + lines.push("| Metric | Value |", "| --- | ---: |"); + lines.push(`| Tokens | ${formatInt(lifetime.total)} |`); + lines.push(`| Sessions | ${formatInt(lifetime.sessions)} |`); + lines.push(`| Active days | ${formatInt(lifetime.days)} |`); + lines.push(`| Messages | ${formatInt(lifetime.messages)} |`); + lines.push(`| Peak day | ${dashboard.peak?.day ? `${formatInt(dashboard.peak.tokens)} tokens on ${dayLabel(dashboard.peak.day)}` : "—"} |`); + lines.push(`| Longest task (observed) | ${formatDuration(lifetime.longestTaskMs)} |`); + lines.push(`| Longest streak | ${formatInt(dashboard.streaks?.longest)} days |`); + lines.push(`| Current streak | ${formatInt(dashboard.streaks?.current)} days |`, ""); + + lines.push(`## ${selectedDay === dashboard.today ? "Today" : "Selected day"} — ${dayLabel(selectedDay, { weekday: true })}`, ""); + lines.push("| Metric | Value |", "| --- | ---: |"); + lines.push(`| Tokens | ${formatInt(selectedTotals.total)} |`); + lines.push(`| Input | ${formatInt(selectedTotals.input)} |`); + lines.push(`| Output | ${formatInt(selectedTotals.output)} |`); + lines.push(`| Reasoning (inside output) | ${formatInt(selectedTotals.reasoning)} |`); + lines.push(`| Cache reads | ${formatInt(selectedTotals.cacheRead)} |`); + lines.push(`| Cache writes | ${formatInt(selectedTotals.cacheWrite)} |`); + lines.push(`| Cache hit rate | ${formatHitRate(selectedTotals.input, selectedTotals.cacheRead)} |`); + lines.push(`| Sessions | ${formatInt(selectedTotals.sessions)} |`); + lines.push(`| Messages | ${formatInt(selectedTotals.messages)} |`); + lines.push(`| Top model | ${selectedTop ? `${markdownCell(selectedTop.name)} (${formatInt(selectedTop.tokens)} tokens)` : "—"} |`, ""); + + lines.push(`## ${rangeLabel} model usage`, ""); + if (!Array.isArray(leaders.entries) || leaders.entries.length === 0) { + lines.push("_No usage in this period._", ""); + } else { + lines.push(`${formatInt(leaders.totalTokens)} tokens · ${formatInt(leaders.totalSessions)} sessions · ${formatInt(leaders.totalGroups)} models`, ""); + lines.push(leaders.groupBy === "provider" + ? "| # | Provider | Models | Tokens | Share | Sessions | vs previous |" + : "| # | Model | Provider(s) | Tokens | Share | Sessions | vs previous |"); + lines.push("| ---: | --- | --- | ---: | ---: | ---: | --- |"); + for (const entry of leaders.entries) { + lines.push(`| ${formatInt(entry.rank)} | ${markdownCell(entry.name)} | ${markdownCell(entry.sublabel)} | ${formatInt(entry.tokens)} | ${round1(Number(entry.sharePct) || 0)}% | ${formatInt(entry.sessions)} | ${markdownChange(entry)} |`); + } + if (leaders.totalGroups > leaders.entries.length) { + lines.push("", `Showing top ${leaders.entries.length} of ${leaders.totalGroups} by tokens.`); + } + lines.push(""); + } + + const weeks = Array.isArray(heatmap.weeks) ? heatmap.weeks : []; + const activeWeeks = weeks.filter((week) => { + const first = week?.days?.[0]; + return Number(first?.weekTotal) > 0 || Number(first?.weekSessions) > 0; + }); + if (activeWeeks.length > 0) { + lines.push(`## Token history — ${formatInt(heatmap.windowTotal)} tokens in window`, ""); + lines.push("| Week of | Tokens | Sessions |"); + lines.push("| --- | ---: | ---: |"); + for (const week of activeWeeks) { + const first = week?.days?.[0]; + lines.push(`| ${dayLabel(week.start)} | ${formatInt(first?.weekTotal)} | ${formatInt(first?.weekSessions)} |`); + } + if (activeWeeks.length < weeks.length) lines.push("", "Weeks without usage are omitted."); + lines.push(""); + } + + return `${lines.join("\n")}\n`; +} + function isDashboard(value) { const object = (item) => item !== null && typeof item === "object" && !Array.isArray(item); const arrayOf = (items, predicate) => Array.isArray(items) && items.every(predicate); diff --git a/tests/herdr-insights/analytics.test.mjs b/tests/herdr-insights/analytics.test.mjs index 2c3c036..a8591c7 100644 --- a/tests/herdr-insights/analytics.test.mjs +++ b/tests/herdr-insights/analytics.test.mjs @@ -14,8 +14,10 @@ const { availableYears, buildHeatmap, buildModelSeries, + compactDashboard, compareChange, createDashboardCache, + dashboardMarkdown, dayKey, dayLabel, deriveDashboard, @@ -104,6 +106,68 @@ test("local calendar arithmetic handles leap days and DST", () => { assert.equal(dayLabel("2024-01-03", { year: false }), "Jan 3"); }); +test("dashboardMarkdown exports exact compact values without re-deriving", () => { + const snapshot = { + config: { includeCache: true, weekStartsOn: 0, sources: [] }, + rows: [ + row({ day: "2024-01-09", session: "s1", tokens: { input: 1000, output: 200, cacheRead: 5000, cacheWrite: 100 }, messages: 2, longestTaskMs: 3600000 }), + row({ day: "2024-01-10", session: "s1", tokens: { input: 500, output: 100, cacheRead: 2000, cacheWrite: 0 }, messages: 1, longestTaskMs: 7200000 }), + row({ day: "2024-01-10", session: "s2", model: "claude-test", provider: "anthropic", tokens: { input: 300, output: 50, cacheRead: 100, cacheWrite: 0 }, messages: 1, longestTaskMs: 1800000 }), + ], + }; + const options = { today: "2024-01-10", period: "month", selectedDay: "2024-01-10" }; + const markdown = dashboardMarkdown(compactDashboard(deriveDashboard(snapshot, options)), { + now: new Date(2024, 0, 10, 19, 7), + }); + + assert.match(markdown, /^# Insights — January 2024/m); + assert.match(markdown, /- Generated: 2024-01-10 19:07 \(local\)/); + assert.match(markdown, /- Tokens: input \+ output \+ cache reads \+ cache writes/); + assert.match(markdown, /\| Tokens \| 9,350 \|/); + assert.match(markdown, /\| Sessions \| 2 \|/); + assert.match(markdown, /\| Peak day \| 6,300 tokens on Jan 9, 2024 \|/); + assert.match(markdown, /\| Input \| 800 \|/); + assert.match(markdown, /\| Output \| 150 \|/); + assert.match(markdown, /\| Cache reads \| 2,100 \|/); + assert.match(markdown, /\| Cache hit rate \| 72.4% \|/); + assert.match(markdown, /\| Top model \| gpt-test \(2,600 tokens\) \|/); + assert.match(markdown, /\| 1 \| gpt-test \| openai \| 8,900 \| 95.2% \| 1 \| New \|/); + assert.match(markdown, /\| Jan 7, 2024 \| 9,350 \| 2 \|/); + assert.match(markdown, /Weeks without usage are omitted\./); + + const excluded = dashboardMarkdown(compactDashboard(deriveDashboard({ + ...snapshot, + config: { ...snapshot.config, includeCache: false }, + }, options)), { now: new Date(2024, 0, 10, 19, 7) }); + assert.match(excluded, /- Tokens: input \+ output \(cache excluded\)/); + assert.match(excluded, /\| Tokens \| 2,150 \|/); +}); + +test("dashboardMarkdown handles an empty dashboard and escapes table cells", () => { + const empty = dashboardMarkdown(compactDashboard(deriveDashboard({ + config: { includeCache: true, weekStartsOn: 0, sources: [] }, + rows: [], + }, { today: "2024-01-10" })), { now: new Date(2024, 0, 10, 19, 7) }); + assert.match(empty, /_No usage in this period\._/); + assert.doesNotMatch(empty, /## Token history/); + + const escaped = dashboardMarkdown({ + includeCache: true, + today: "2024-01-10", + range: { label: "All time", start: null, end: "2024-01-10" }, + filters: { provider: "all", model: "all" }, + lifetime: { total: 10, sessions: 1, days: 1, messages: 1, longestTaskMs: 0 }, + peak: { day: null, tokens: 0 }, + streaks: { current: 0, longest: 0 }, + selectedDay: "2024-01-10", + selectedTotals: { total: 10, input: 10, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, sessions: 1, messages: 1 }, + selectedTop: { name: "model|with|pipes", tokens: 10 }, + leaders: { entries: [{ rank: 1, name: "model|with|pipes", sublabel: "provider|one", tokens: 10, sharePct: 100, sessions: 1, previous: 0, change: { kind: "new", label: "New" } }], totalTokens: 10, totalSessions: 1, totalGroups: 1 }, + heatmap: { windowTotal: 10, weeks: [] }, + }, { now: new Date(2024, 0, 10, 19, 7) }); + assert.match(escaped, /model\\\|with\\\|pipes/); +}); + test("heatmap is 53 local weeks of 7 consecutive days", () => { const heatmap = buildHeatmap([], { endDay: "2024-03-10", weeks: 53, weekStartsOn: 0 }); assert.equal(heatmap.weeks.length, 53); From 197cf764b7dcb9943ce0324a0be1f27fb435e408 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Fri, 6 Nov 2026 20:07:17 +0530 Subject: [PATCH 2/5] feat(insights): add Export dropdown to dashboard Put Export between Refresh and Settings with As Markdown as its first action. The menu sizes to its content from shared layout tokens, and export writes the prepared dashboard through the folder picker. --- herdr-insights/mods/insights/main.js | 93 +++++++++++++- tests/herdr-insights/analytics-ui.test.mjs | 138 ++++++++++++++++++++- 2 files changed, 227 insertions(+), 4 deletions(-) diff --git a/herdr-insights/mods/insights/main.js b/herdr-insights/mods/insights/main.js index 51d44ac..cc9f271 100644 --- a/herdr-insights/mods/insights/main.js +++ b/herdr-insights/mods/insights/main.js @@ -1,10 +1,11 @@ // Insights: a native, local-only usage dashboard. No DOM, no shell, no stored cx. import { View, div, svg } from "gpui-kit"; import { v_flex, h_flex, Button, Input, InputState, Editor, EditorState, Popover } from "gpui-base"; -import { exec, appHome, home, theme, settings, nextCommand, panes, props, run, readTextFile, spaces } from "herdr"; +import { exec, appHome, home, theme, settings, nextCommand, panes, props, run, readTextFile, writeTextFile, pickFolder, notify, spaces } from "herdr"; import { PALETTES, clampRefreshSeconds, + dashboardMarkdown, dayLabel, formatBytes, formatCompact, @@ -57,6 +58,13 @@ function shortFileName(value) { return index >= 0 ? value.slice(index + 1) : value; } +/** Local-time filename stamp, so two exports never reuse a path. */ +function exportStamp(date) { + const two = (value) => String(value).padStart(2, "0"); + return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}` + + `-${two(date.getHours())}${two(date.getMinutes())}${two(date.getSeconds())}`; +} + /** One Insights tab per space: focus it if it exists, otherwise create it. */ function showInsights() { const active = spaces().find((space) => space.active); @@ -468,6 +476,10 @@ export default class Insights extends View { this.rootsDirty = false; this.refreshDirty = false; this.saving = false; + this.exporting = false; + this.exportError = null; + this.exportTask = null; + this.exportMenuOpen = false; this.saveError = null; this.saveMessage = null; this.syncingInputs = false; @@ -557,6 +569,8 @@ export default class Insights extends View { try { this.progressTimer?.cancel(); } catch { /* already gone */ } this.cancelScanContinue(); try { this.saveWatchdog?.cancel(); } catch { /* already gone */ } + try { this.exportTask?.cancel(); } catch { /* already gone */ } + this.exportTask = null; try { this.settingsTask?.cancel(); } catch { /* already gone */ } try { this.commandTask?.cancel(); } catch { /* already gone */ } try { this.runtimeTask?.cancel(); } catch { /* already gone */ } @@ -1055,6 +1069,82 @@ export default class Insights extends View { }); } + exportMenu(c) { + const disabled = !this.dashboard || this.exporting || this.busy(); + const trigger = Button.new("insights-export-trigger").h(H.control).px(G.sm).gap(G.xs).items_center().rounded(R.control) + .border_1().border_color(c.border).bg(c.card) + .when(disabled, (el) => el.opacity(0.5)) + .when(!disabled, (el) => el.cursor_pointer().hover((style) => style.bg(c.subtle))) + .focus((style) => style.border_color(c.ring)) + .disabled(disabled) + .accessibility_label("Export") + .child(div().text_size(T.body).font_medium().text_color(c.text).child(this.exporting ? "Exporting…" : "Export")) + .child(div().text_size(T.small).text_color(c.dim).child("⌄")); + + const open = this.exportMenuOpen && !disabled; + const menu = Popover.new("insights-export-menu").trigger(trigger) + .open(open) + .on_open_change((next, changeCx) => { + this.exportMenuOpen = next; + changeCx.notify(); + }); + if (open) { + menu.content(v_flex().min_w(0).p(G.xs).gap(0).rounded(R.control) + .bg(c.popover).border_1().border_color(c.border) + .child(Button.new("insights-export-markdown") + .h(H.compact).px(G.sm).justify_start().rounded(R.control) + .set_position(1, 1) + .cursor_pointer() + .hover((style) => style.bg(c.subtle)) + .focus((style) => style.bg(c.selected)) + .accessibility_label("As Markdown") + .on_click((_event, clickCx) => { + this.exportMenuOpen = false; + this.exportMarkdown(clickCx); + clickCx.notify(); + }) + .child(div().text_size(T.body).text_color(c.text).child("As Markdown")))); + } + return menu; + } + + exportMarkdown(cx) { + if (this.closed || !this.dashboard || this.exporting || this.busy()) return; + this.exportMenuOpen = false; + const now = new Date(); + const markdown = dashboardMarkdown(this.dashboard, { now }); + this.exporting = true; + this.exportError = null; + cx.notify(); + let task = null; + task = cx.spawn(async (exportCx) => { + let failure = null; + let savedPath = null; + try { + const folder = await pickFolder({ prompt: "Export" }); + if (!folder) return; + const base = String(folder).replace(/[\\/]+$/, ""); + savedPath = `${base}/insights-${exportStamp(now)}.md`; + await writeTextFile(savedPath, markdown, null); + } catch (error) { + failure = error; + } finally { + if (!this.closed) { + this.exporting = false; + if (this.exportTask === task) this.exportTask = null; + if (failure) { + this.exportError = sanitizeError(failure); + notify({ title: "Could not export Insights", body: this.exportError, level: "error" }); + } else if (savedPath) { + notify({ title: "Insights exported", body: savedPath, level: "success" }); + } + exportCx.notify(); + } + } + }); + this.exportTask = task; + } + applySnapshot(payload, cx) { if (!isPresentation(payload)) throw new Error("The presentation helper returned an unexpected dashboard payload."); const snapshot = payload.snapshot; @@ -1461,6 +1551,7 @@ export default class Insights extends View { disabled: this.busy(), variant: "outline", })) + .child(this.exportMenu(c)) .when(Boolean(this.snapshot), (el) => el.child(button( "insights-settings", this.showSettings ? "Dashboard" : "Settings", diff --git a/tests/herdr-insights/analytics-ui.test.mjs b/tests/herdr-insights/analytics-ui.test.mjs index f919363..80b6372 100644 --- a/tests/herdr-insights/analytics-ui.test.mjs +++ b/tests/herdr-insights/analytics-ui.test.mjs @@ -82,7 +82,23 @@ const transformedUi = uiSource `const v_flex = () => globalThis.__insightsTestNative.create("v_flex"); const h_flex = () => globalThis.__insightsTestNative.create("h_flex"); const Button = { new: (id) => globalThis.__insightsTestNative.create("Button") }; -const Toggle = { new: (id) => globalThis.__insightsTestNative.create("Toggle") };`, +const Toggle = { new: (id) => globalThis.__insightsTestNative.create("Toggle") }; +const Popover = { + new(id) { + const node = globalThis.__insightsTestNative.create("Popover"); + node.id = id; + const builder = { + get openState() { return node.openState; }, + get contentEl() { return node.contentEl; }, + get triggerEl() { return node.triggerEl; }, + trigger(element) { node.triggerEl = element; return builder; }, + open(value) { node.openState = value; return builder; }, + on_open_change(handler) { node.onOpenChange = handler; return builder; }, + content(element) { node.contentEl = element; return builder; }, + }; + return builder; + }, +};`, ); const uiUrl = dataUrl(transformedUi); const ui = await import(uiUrl); @@ -98,7 +114,23 @@ const svg = () => globalThis.__insightsTestNative.create("svg");`) const EditorState = globalThis.__insightsTestEditorState; const v_flex = () => globalThis.__insightsTestNative.create("v_flex"); const h_flex = () => globalThis.__insightsTestNative.create("h_flex"); -const Button = { new: (id) => globalThis.__insightsTestNative.create("Button") };`, +const Button = { new: (id) => globalThis.__insightsTestNative.create("Button") }; +const Popover = { + new(id) { + const node = globalThis.__insightsTestNative.create("Popover"); + node.id = id; + const builder = { + get openState() { return node.openState; }, + get contentEl() { return node.contentEl; }, + get triggerEl() { return node.triggerEl; }, + trigger(element) { node.triggerEl = element; return builder; }, + open(value) { node.openState = value; return builder; }, + on_open_change(handler) { node.onOpenChange = handler; return builder; }, + content(element) { node.contentEl = element; return builder; }, + }; + return builder; + }, +};`, ) .replace( /^import \{[^}]*\} from "herdr";$/m, @@ -109,6 +141,9 @@ const settings = () => ({}); const nextCommand = () => globalThis.__insightsTestHerdr.nextCommand(); const exec = (...args) => globalThis.__insightsTestHerdr.exec(...args); const readTextFile = (...args) => globalThis.__insightsTestHerdr.readTextFile(...args); +const writeTextFile = (...args) => globalThis.__insightsTestHerdr.writeTextFile(...args); +const pickFolder = (...args) => globalThis.__insightsTestHerdr.pickFolder(...args); +const notify = (...args) => globalThis.__insightsTestHerdr.notify(...args); const panes = () => globalThis.__insightsTestHerdr.panes(); const props = () => globalThis.__insightsTestHerdr.props(); const run = (...args) => globalThis.__insightsTestHerdr.run(...args); @@ -147,7 +182,10 @@ function presentationFor(snapshot, options, command = "snapshot") { } function useHerdr({ readTextFile, execResult } = {}) { - const state = { commands: [], execCalls: [], panes: [], props: {}, runtimeReads: [], spaces: [] }; + const state = { + commands: [], execCalls: [], panes: [], props: {}, runtimeReads: [], spaces: [], + exportFolder: "/exports", exportWrites: [], notifications: [], + }; state.herdr = { appHome: () => "/app", home: () => "/home", @@ -162,6 +200,15 @@ function useHerdr({ readTextFile, execResult } = {}) { state.runtimeReads.push(path); return readTextFile ? readTextFile(path) : null; }, + writeTextFile: async (path, text, expected) => { + state.exportWrites.push({ path, text, expected }); + return null; + }, + pickFolder: async () => state.exportFolder, + notify: (notification) => { + state.notifications.push(notification); + return "toast-1"; + }, exec: async (command, args) => { state.execCalls.push({ command, args }); if (execResult) return execResult(command, args, state); @@ -249,6 +296,11 @@ function blankInstance() { instance.years = [2024]; instance.dashboard = null; instance.dashboardKey = null; + instance.exporting = false; + instance.exportError = null; + instance.exportTask = null; + instance.exportMenuOpen = false; + instance.closed = false; instance.dashboardPending = null; instance.dashboardError = null; instance.dashboardTimer = null; @@ -328,6 +380,80 @@ test("native mod keeps timers and shell use inside the Herdr contract", () => { assert.doesNotMatch(mainSource, /rounded\([12][0-9]\)/); }); +test("export writes the current dashboard as Markdown without running the collector", async () => { + const state = useHerdr(); + const instance = blankInstance(); + const snapshot = savedSnapshot(); + const rows = [{ + day: "2024-01-10", + session: "s1", + harness: "pi", + provider: "openai", + model: "gpt-test", + project: "/repo/insights", + tokens: { input: 10, output: 5, cacheRead: 0, cacheWrite: 0 }, + messages: 1, + activeMs: 1000, + longestTaskMs: 1000, + }]; + instance.snapshot = { ...snapshot, rows, rowCount: rows.length }; + instance.dashboard = compactDashboard(deriveDashboard({ ...snapshot, rows }, { + filters: instance.filters, + period: "all", + year: 2024, + heatmapMode: "daily", + selectedDay: "2024-01-10", + today: "2024-01-10", + })); + const { cx } = liveCx(); + + instance.exportMarkdown(cx); + assert.equal(instance.exporting, true, "the button disables while the picker is open"); + await flush(); + await flush(); + + assert.equal(state.exportWrites.length, 1); + assert.match(state.exportWrites[0].path, /^\/exports\/insights-\d{4}-\d{2}-\d{2}-\d{6}\.md$/); + assert.equal(state.exportWrites[0].expected, null); + assert.match(state.exportWrites[0].text, /^# Insights — All time/m); + assert.match(state.exportWrites[0].text, /\| Tokens \| 15 \|/); + assert.deepEqual(state.execCalls, [], "export must reuse the prepared dashboard, not rescan"); + assert.equal(state.notifications.length, 1); + assert.equal(state.notifications[0].level, "success"); + assert.equal(instance.exporting, false); + + state.exportFolder = null; + instance.exportMarkdown(cx); + await flush(); + await flush(); + assert.equal(state.exportWrites.length, 1, "cancelling the picker writes nothing"); + assert.equal(state.notifications.length, 1, "cancelling the picker is silent"); + assert.equal(instance.exporting, false); +}); + +test("export is a dropdown with Markdown as its first action", () => { + const instance = blankInstance(); + instance.dashboard = { today: "2024-01-10" }; + instance.exportMenuOpen = true; + const menu = instance.exportMenu({}); + + assert.equal(menu.openState, true); + assert.ok(menu.contentEl, "the open menu renders its actions"); + const item = menu.contentEl.childNodes[0]; + assert.match(item.calls.find(([name]) => name === "accessibility_label")[1][0], /As Markdown/); + assert.deepEqual(item.childNodes[0].childNodes, ["As Markdown"], "the action has no subtext"); + + let exported = false; + let notified = false; + instance.exportMarkdown = () => { exported = true; }; + const click = item.calls.find(([name]) => name === "on_click")[1][0]; + click({}, { notify() { notified = true; } }); + + assert.equal(exported, true); + assert.equal(instance.exportMenuOpen, false, "choosing an action closes the menu"); + assert.equal(notified, true); +}); + test("analytics stays host-free and the UI keeps configuration JSON editable", () => { assert.doesNotMatch(analyticsSource, /from\s+["']herdr["']/); assert.doesNotMatch(analyticsSource, /from\s+["']gpui-kit["']/); @@ -548,6 +674,12 @@ test("refresh and configure share one single-flight guard", () => { instance.saving = false; assert.equal(instance.busy(), false); assert.match(mainSource, /button\("insights-refresh",[^;]*?disabled: this\.busy\(\)/); + const headerAt = mainSource.indexOf(" header(c) {"); + const refreshAt = mainSource.indexOf('"insights-refresh"', headerAt); + const exportAt = mainSource.indexOf("this.exportMenu(c)", headerAt); + const settingsAt = mainSource.indexOf('"insights-settings"', headerAt); + assert.ok(headerAt > 0 && refreshAt > headerAt && exportAt > refreshAt && settingsAt > exportAt, + "the Export dropdown sits between Refresh and Settings"); assert.match(mainSource, /button\("ins-config-save",[\s\S]*?disabled: this\.busy\(\)/); assert.doesNotMatch(mainSource, /insights-save-profile|profileDirty|AvatarImage|nameInput|handleInput|avatarInput/); }); From 9dd3b84bb0cdde45fec12532008e8d3c7dd3f6c6 Mon Sep 17 00:00:00 2001 From: GaganSD Date: Sat, 7 Nov 2026 06:21:49 +0530 Subject: [PATCH 3/5] refactor(insights): make Export a single toolbar button --- herdr-insights/mods/insights/analytics.js | 8 +-- herdr-insights/mods/insights/main.js | 61 ++++------------------ tests/herdr-insights/analytics-ui.test.mjs | 39 +++++++------- 3 files changed, 30 insertions(+), 78 deletions(-) diff --git a/herdr-insights/mods/insights/analytics.js b/herdr-insights/mods/insights/analytics.js index 28a80ca..e649510 100644 --- a/herdr-insights/mods/insights/analytics.js +++ b/herdr-insights/mods/insights/analytics.js @@ -1165,11 +1165,6 @@ function markdownCell(value) { return String(value ?? "").replace(/\|/g, "\\|").replace(/\s+/g, " ").trim(); } -function markdownDateTime(date) { - if (!(date instanceof Date) || Number.isNaN(date.getTime())) return ""; - return `${dayKey(date)} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`; -} - function markdownChange(entry) { const change = entry?.change; if (!change || change.kind === "none") return "—"; @@ -1187,9 +1182,8 @@ function markdownChange(entry) { * dashboard's compact K/M/B labels are intentionally not reused. */ export function dashboardMarkdown(dashboard, options = {}) { - if (!dashboard || typeof dashboard !== "object") return ""; const now = options.now instanceof Date ? options.now : new Date(options.now ?? Date.now()); - const generated = markdownDateTime(now); + const generated = Number.isNaN(now.getTime()) ? "" : `${dayKey(now)} ${pad2(now.getHours())}:${pad2(now.getMinutes())}`; const includeCache = dashboard.includeCache !== false; const range = dashboard.range ?? {}; const lifetime = dashboard.lifetime ?? {}; diff --git a/herdr-insights/mods/insights/main.js b/herdr-insights/mods/insights/main.js index cc9f271..46c5fa5 100644 --- a/herdr-insights/mods/insights/main.js +++ b/herdr-insights/mods/insights/main.js @@ -6,6 +6,7 @@ import { PALETTES, clampRefreshSeconds, dashboardMarkdown, + dayKey, dayLabel, formatBytes, formatCompact, @@ -61,8 +62,7 @@ function shortFileName(value) { /** Local-time filename stamp, so two exports never reuse a path. */ function exportStamp(date) { const two = (value) => String(value).padStart(2, "0"); - return `${date.getFullYear()}-${two(date.getMonth() + 1)}-${two(date.getDate())}` - + `-${two(date.getHours())}${two(date.getMinutes())}${two(date.getSeconds())}`; + return `${dayKey(date)}-${two(date.getHours())}${two(date.getMinutes())}${two(date.getSeconds())}`; } /** One Insights tab per space: focus it if it exists, otherwise create it. */ @@ -477,9 +477,7 @@ export default class Insights extends View { this.refreshDirty = false; this.saving = false; this.exporting = false; - this.exportError = null; this.exportTask = null; - this.exportMenuOpen = false; this.saveError = null; this.saveMessage = null; this.syncingInputs = false; @@ -1069,55 +1067,13 @@ export default class Insights extends View { }); } - exportMenu(c) { - const disabled = !this.dashboard || this.exporting || this.busy(); - const trigger = Button.new("insights-export-trigger").h(H.control).px(G.sm).gap(G.xs).items_center().rounded(R.control) - .border_1().border_color(c.border).bg(c.card) - .when(disabled, (el) => el.opacity(0.5)) - .when(!disabled, (el) => el.cursor_pointer().hover((style) => style.bg(c.subtle))) - .focus((style) => style.border_color(c.ring)) - .disabled(disabled) - .accessibility_label("Export") - .child(div().text_size(T.body).font_medium().text_color(c.text).child(this.exporting ? "Exporting…" : "Export")) - .child(div().text_size(T.small).text_color(c.dim).child("⌄")); - - const open = this.exportMenuOpen && !disabled; - const menu = Popover.new("insights-export-menu").trigger(trigger) - .open(open) - .on_open_change((next, changeCx) => { - this.exportMenuOpen = next; - changeCx.notify(); - }); - if (open) { - menu.content(v_flex().min_w(0).p(G.xs).gap(0).rounded(R.control) - .bg(c.popover).border_1().border_color(c.border) - .child(Button.new("insights-export-markdown") - .h(H.compact).px(G.sm).justify_start().rounded(R.control) - .set_position(1, 1) - .cursor_pointer() - .hover((style) => style.bg(c.subtle)) - .focus((style) => style.bg(c.selected)) - .accessibility_label("As Markdown") - .on_click((_event, clickCx) => { - this.exportMenuOpen = false; - this.exportMarkdown(clickCx); - clickCx.notify(); - }) - .child(div().text_size(T.body).text_color(c.text).child("As Markdown")))); - } - return menu; - } - exportMarkdown(cx) { if (this.closed || !this.dashboard || this.exporting || this.busy()) return; - this.exportMenuOpen = false; const now = new Date(); const markdown = dashboardMarkdown(this.dashboard, { now }); this.exporting = true; - this.exportError = null; cx.notify(); - let task = null; - task = cx.spawn(async (exportCx) => { + this.exportTask = cx.spawn(async (exportCx) => { let failure = null; let savedPath = null; try { @@ -1131,10 +1087,9 @@ export default class Insights extends View { } finally { if (!this.closed) { this.exporting = false; - if (this.exportTask === task) this.exportTask = null; + this.exportTask = null; if (failure) { - this.exportError = sanitizeError(failure); - notify({ title: "Could not export Insights", body: this.exportError, level: "error" }); + notify({ title: "Could not export Insights", body: sanitizeError(failure), level: "error" }); } else if (savedPath) { notify({ title: "Insights exported", body: savedPath, level: "success" }); } @@ -1142,7 +1097,6 @@ export default class Insights extends View { } } }); - this.exportTask = task; } applySnapshot(payload, cx) { @@ -1551,7 +1505,10 @@ export default class Insights extends View { disabled: this.busy(), variant: "outline", })) - .child(this.exportMenu(c)) + .child(button("insights-export", this.exporting ? "Exporting…" : "Export", (_event, clickCx) => this.exportMarkdown(clickCx), c, { + disabled: !this.dashboard || this.exporting || this.busy(), + variant: "outline", + })) .when(Boolean(this.snapshot), (el) => el.child(button( "insights-settings", this.showSettings ? "Dashboard" : "Settings", diff --git a/tests/herdr-insights/analytics-ui.test.mjs b/tests/herdr-insights/analytics-ui.test.mjs index 80b6372..12f7b60 100644 --- a/tests/herdr-insights/analytics-ui.test.mjs +++ b/tests/herdr-insights/analytics-ui.test.mjs @@ -297,9 +297,7 @@ function blankInstance() { instance.dashboard = null; instance.dashboardKey = null; instance.exporting = false; - instance.exportError = null; instance.exportTask = null; - instance.exportMenuOpen = false; instance.closed = false; instance.dashboardPending = null; instance.dashboardError = null; @@ -431,27 +429,30 @@ test("export writes the current dashboard as Markdown without running the collec assert.equal(instance.exporting, false); }); -test("export is a dropdown with Markdown as its first action", () => { +test("export is a single toolbar button that starts a Markdown export", () => { const instance = blankInstance(); instance.dashboard = { today: "2024-01-10" }; - instance.exportMenuOpen = true; - const menu = instance.exportMenu({}); - - assert.equal(menu.openState, true); - assert.ok(menu.contentEl, "the open menu renders its actions"); - const item = menu.contentEl.childNodes[0]; - assert.match(item.calls.find(([name]) => name === "accessibility_label")[1][0], /As Markdown/); - assert.deepEqual(item.childNodes[0].childNodes, ["As Markdown"], "the action has no subtext"); - let exported = false; - let notified = false; instance.exportMarkdown = () => { exported = true; }; - const click = item.calls.find(([name]) => name === "on_click")[1][0]; - click({}, { notify() { notified = true; } }); + const findExportButton = (node) => { + if (!node || typeof node !== "object") return null; + const label = Array.isArray(node.calls) + ? node.calls.find(([name]) => name === "accessibility_label") + : null; + if (label?.[1]?.[0] === "Export") return node; + for (const child of node.childNodes ?? []) { + const found = findExportButton(child); + if (found) return found; + } + return null; + }; + + const exportButton = findExportButton(instance.header({})); + assert.ok(exportButton, "the header renders an Export button"); + const click = exportButton.calls.find(([name]) => name === "on_click")[1][0]; + click({}, { notify() {} }); assert.equal(exported, true); - assert.equal(instance.exportMenuOpen, false, "choosing an action closes the menu"); - assert.equal(notified, true); }); test("analytics stays host-free and the UI keeps configuration JSON editable", () => { @@ -676,10 +677,10 @@ test("refresh and configure share one single-flight guard", () => { assert.match(mainSource, /button\("insights-refresh",[^;]*?disabled: this\.busy\(\)/); const headerAt = mainSource.indexOf(" header(c) {"); const refreshAt = mainSource.indexOf('"insights-refresh"', headerAt); - const exportAt = mainSource.indexOf("this.exportMenu(c)", headerAt); + const exportAt = mainSource.indexOf('"insights-export"', headerAt); const settingsAt = mainSource.indexOf('"insights-settings"', headerAt); assert.ok(headerAt > 0 && refreshAt > headerAt && exportAt > refreshAt && settingsAt > exportAt, - "the Export dropdown sits between Refresh and Settings"); + "the Export button sits between Refresh and Settings"); assert.match(mainSource, /button\("ins-config-save",[\s\S]*?disabled: this\.busy\(\)/); assert.doesNotMatch(mainSource, /insights-save-profile|profileDirty|AvatarImage|nameInput|handleInput|avatarInput/); }); From 803c7f5aa162c457d44b85f23526da55db91da1d Mon Sep 17 00:00:00 2001 From: GaganSD Date: Sat, 7 Nov 2026 06:21:49 +0530 Subject: [PATCH 4/5] chore(insights): remove unused helpers and dead scan path --- herdr-insights/mods/insights/analytics.js | 30 ------------- herdr-insights/mods/insights/core/config.mjs | 4 -- herdr-insights/mods/insights/core/scan.mjs | 45 -------------------- herdr-insights/mods/insights/core/store.mjs | 39 +---------------- herdr-insights/mods/insights/ui.js | 4 -- 5 files changed, 1 insertion(+), 121 deletions(-) diff --git a/herdr-insights/mods/insights/analytics.js b/herdr-insights/mods/insights/analytics.js index e649510..d7bea1b 100644 --- a/herdr-insights/mods/insights/analytics.js +++ b/herdr-insights/mods/insights/analytics.js @@ -132,11 +132,6 @@ export function startOfWeek(day, weekStartsOn = 0) { return addDays(day, -offset); } -export function endOfWeek(day, weekStartsOn = 0) { - const start = startOfWeek(day, weekStartsOn); - return start ? addDays(start, 6) : null; -} - export function weekdayLabels(weekStartsOn = 0) { const names = weekStartsOn === 1 ? ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] @@ -286,18 +281,6 @@ export function normalizeConfig(config) { }; } -export function isSnapshot(value) { - return Boolean( - value - && typeof value === "object" - && value.version === 1 - && Array.isArray(value.rows) - && value.config - && typeof value.config === "object" - && !Array.isArray(value.config), - ); -} - export function normalizeTokens(tokens) { const value = tokens && typeof tokens === "object" ? tokens : {}; const hasParts = ["input", "output", "cacheRead", "cacheWrite"].some((key) => Object.prototype.hasOwnProperty.call(value, key)); @@ -397,10 +380,6 @@ function matchesCompiled(row, filters) { return true; } -export function matchesFilters(row, filters = {}) { - return matchesCompiled(row, compileFilters(filters)); -} - export function filterRows(rows, filters = {}) { const compiled = compileFilters(filters); return (rows ?? []).filter((row) => matchesCompiled(row, compiled)); @@ -571,15 +550,6 @@ export function streakStats(rows, options = {}) { return { current, longest, activeDays: days.length, lastActive: previous }; } -export function dailyTotals(rows, includeCache = true) { - const totals = new Map(); - for (const row of rows ?? []) { - if (!isDay(row?.day)) continue; - totals.set(row.day, (totals.get(row.day) ?? 0) + rowTokenTotal(row, includeCache)); - } - return totals; -} - export function peakDay(rows, includeCache = true) { const totals = new Map(); const sessions = new Map(); diff --git a/herdr-insights/mods/insights/core/config.mjs b/herdr-insights/mods/insights/core/config.mjs index 5557444..b0aa9d7 100644 --- a/herdr-insights/mods/insights/core/config.mjs +++ b/herdr-insights/mods/insights/core/config.mjs @@ -401,7 +401,3 @@ export function sourcesSignature(config) { export function defaultConfig() { return normalizeConfig(DEFAULT_CONFIG); } - -export function adapterForSource(source) { - return source.adapter; -} diff --git a/herdr-insights/mods/insights/core/scan.mjs b/herdr-insights/mods/insights/core/scan.mjs index 3db2855..d489d95 100644 --- a/herdr-insights/mods/insights/core/scan.mjs +++ b/herdr-insights/mods/insights/core/scan.mjs @@ -561,51 +561,6 @@ function createRecordConsumer(store, entry, source, sink) { }); } -async function parseBuffers( - store, - entry, - source, - chunks, - startOffset, - endOffset, - sink, - options = {}, -) { - const consumer = createRecordConsumer(store, entry, source, sink); - consumer.offset = startOffset; - consumer.lineNo = entry.lineNo ?? 0; - const now = nowFn(options); - const delay = pausableDelay(options.streamDelayMs); - let chunkStart = startOffset; - let chunksSeen = 0; - let bytes = 0; - for await (const chunk of chunks) { - if (chunksSeen > 0) { - if (stopRequested(options)) throw new ScanStopError("stop"); - if (deadlineReached(options, now())) throw new ScanStopError("budget"); - } - if (delay) await delay(); - consumer.feedAt(chunk, chunkStart); - chunkStart += chunk.length; - bytes += chunk.length; - chunksSeen += 1; - if (chunksSeen % PROGRESS_CHUNK_INTERVAL === 0) { - options.onStreamProgress?.({ - file: entry.path, - bytes, - phase: "decompress", - }); - } - } - consumer.finish(endOffset ?? chunkStart); - if (consumer.skippedLargeLine) { - sink.warn( - `${source.adapter} ${entry.path}: a line larger than ${MAX_LINE_BYTES} bytes was skipped.`, - ); - } - return { offset: consumer.offset, lineNo: consumer.lineNo, bytes }; -} - async function scanPlainFile( store, entry, diff --git a/herdr-insights/mods/insights/core/store.mjs b/herdr-insights/mods/insights/core/store.mjs index 26a6ce4..210e92a 100644 --- a/herdr-insights/mods/insights/core/store.mjs +++ b/herdr-insights/mods/insights/core/store.mjs @@ -1,4 +1,4 @@ -import { readFile, stat, unlink } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import { storeRevisionFingerprint, writePresentationCache } from "./cache.mjs"; import { defaultConfig, migrateLoadedConfig, normalizeConfig } from "./config.mjs"; import { @@ -683,28 +683,6 @@ export function removeLatestFallbackTaskForSession(store, session) { return null; } -export function removeFallbackTasksForSession(store, file, session) { - if (!Array.isArray(file.tasks) || file.tasks.length === 0) return; - store._tx?.trackFile(file); - const sHash = sessionHash(session); - const keep = []; - for (const key of file.tasks) { - const entry = store.tasks[key]; - if ( - Array.isArray(entry) && - entry[0] === file.id && - entry[3] === 0 && - entry[4] === sHash - ) { - store._tx?.touchTask(key); - delete store.tasks[key]; - } else { - keep.push(key); - } - } - file.tasks = keep; -} - function emptyAggregate(meta) { return { day: meta.day, @@ -989,21 +967,6 @@ export async function saveStore( return savedAt; } -export async function fileStatOrNull(file) { - try { - return await stat(file); - } catch (error) { - if (error?.code === "ENOENT") return null; - throw error; - } -} - -export async function removeStoreFile(storePath) { - await unlink(storePath).catch((error) => { - if (error?.code !== "ENOENT") throw error; - }); -} - export function dedupKeyFor(harness, ...parts) { return hashKey("usage", harness, ...parts); } diff --git a/herdr-insights/mods/insights/ui.js b/herdr-insights/mods/insights/ui.js index dec015d..b842811 100644 --- a/herdr-insights/mods/insights/ui.js +++ b/herdr-insights/mods/insights/ui.js @@ -58,10 +58,6 @@ export function pageTitle(text, c) { return div().text_size(T.page).font_semibold().text_color(c.text).child(text); } -export function pageSubtitle(text, c) { - return div().text_size(T.small).text_color(c.mutedForeground).child(text); -} - export function cardTitle(text, c) { return div().text_size(T.card).font_semibold().text_color(c.text).child(text); } From 85c027df27528d6acb32993f652a0ace47857ccb Mon Sep 17 00:00:00 2001 From: GaganSD Date: Sat, 7 Nov 2026 06:30:27 +0530 Subject: [PATCH 5/5] fix(insights): block export while the dashboard is stale --- herdr-insights/mods/insights/main.js | 16 +++++++++++++--- tests/herdr-insights/analytics-ui.test.mjs | 11 +++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/herdr-insights/mods/insights/main.js b/herdr-insights/mods/insights/main.js index 46c5fa5..26d4d86 100644 --- a/herdr-insights/mods/insights/main.js +++ b/herdr-insights/mods/insights/main.js @@ -265,7 +265,7 @@ function parseSnapshot(stdout) { return parsed; } -function presentationKey(snapshot, options = {}) { +export function presentationKey(snapshot, options = {}) { return JSON.stringify({ generatedAt: snapshot?.generatedAt ?? null, lastScanAt: snapshot?.lastScanAt ?? null, @@ -684,6 +684,16 @@ export default class Insights extends View { return this.loading || this.saving; } + dashboardCurrent() { + return Boolean(this.dashboard) + && this.dashboardKey === presentationKey(this.snapshot, this.dashboardOptions()); + } + + exportReady() { + return !this.closed && this.dashboardCurrent() && !this.exporting && !this.busy() + && !this.dashboardPending && !this.dashboardQueued; + } + resolveRuntime(cx) { if (this.closed || (this.runtimeTask && !this.runtimeReady)) return; this.runtimeReady = false; @@ -1068,7 +1078,7 @@ export default class Insights extends View { } exportMarkdown(cx) { - if (this.closed || !this.dashboard || this.exporting || this.busy()) return; + if (!this.exportReady()) return; const now = new Date(); const markdown = dashboardMarkdown(this.dashboard, { now }); this.exporting = true; @@ -1506,7 +1516,7 @@ export default class Insights extends View { variant: "outline", })) .child(button("insights-export", this.exporting ? "Exporting…" : "Export", (_event, clickCx) => this.exportMarkdown(clickCx), c, { - disabled: !this.dashboard || this.exporting || this.busy(), + disabled: !this.exportReady(), variant: "outline", })) .when(Boolean(this.snapshot), (el) => el.child(button( diff --git a/tests/herdr-insights/analytics-ui.test.mjs b/tests/herdr-insights/analytics-ui.test.mjs index 12f7b60..7027bdc 100644 --- a/tests/herdr-insights/analytics-ui.test.mjs +++ b/tests/herdr-insights/analytics-ui.test.mjs @@ -157,7 +157,7 @@ const spaces = () => globalThis.__insightsTestHerdr.spaces();`, /^import \{[\s\S]*?\} from "\.\/ui\.js";$/m, `const { ${uiNames} } = await import("${uiUrl}");`, ); -const { default: Insights, heatmapMetrics, modelLogo, providerLogo, usageLogo } = await import(dataUrl(transformedMain)); +const { default: Insights, heatmapMetrics, modelLogo, presentationKey, providerLogo, usageLogo } = await import(dataUrl(transformedMain)); function optionArgument(args) { const index = args.indexOf("--options"); @@ -403,6 +403,7 @@ test("export writes the current dashboard as Markdown without running the collec selectedDay: "2024-01-10", today: "2024-01-10", })); + instance.dashboardKey = presentationKey(instance.snapshot, instance.dashboardOptions()); const { cx } = liveCx(); instance.exportMarkdown(cx); @@ -427,11 +428,17 @@ test("export writes the current dashboard as Markdown without running the collec assert.equal(state.exportWrites.length, 1, "cancelling the picker writes nothing"); assert.equal(state.notifications.length, 1, "cancelling the picker is silent"); assert.equal(instance.exporting, false); + + instance.filters.provider = "stale"; + instance.exportMarkdown(cx); + await flush(); + assert.equal(state.exportWrites.length, 1, "a stale dashboard is never exported"); + assert.equal(instance.exporting, false); }); test("export is a single toolbar button that starts a Markdown export", () => { const instance = blankInstance(); - instance.dashboard = { today: "2024-01-10" }; + instance.dashboardCurrent = () => true; let exported = false; instance.exportMarkdown = () => { exported = true; };