diff --git a/herdr-insights/mods/insights/analytics.js b/herdr-insights/mods/insights/analytics.js index 0e8dd3c..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(); @@ -1160,6 +1130,114 @@ 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 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 = {}) { + const now = options.now instanceof Date ? options.now : new Date(options.now ?? Date.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 ?? {}; + 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/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/main.js b/herdr-insights/mods/insights/main.js index 51d44ac..26d4d86 100644 --- a/herdr-insights/mods/insights/main.js +++ b/herdr-insights/mods/insights/main.js @@ -1,10 +1,12 @@ // 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, + dayKey, dayLabel, formatBytes, formatCompact, @@ -57,6 +59,12 @@ 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 `${dayKey(date)}-${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); @@ -257,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, @@ -468,6 +476,8 @@ export default class Insights extends View { this.rootsDirty = false; this.refreshDirty = false; this.saving = false; + this.exporting = false; + this.exportTask = null; this.saveError = null; this.saveMessage = null; this.syncingInputs = false; @@ -557,6 +567,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 */ } @@ -672,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; @@ -1055,6 +1077,38 @@ export default class Insights extends View { }); } + exportMarkdown(cx) { + if (!this.exportReady()) return; + const now = new Date(); + const markdown = dashboardMarkdown(this.dashboard, { now }); + this.exporting = true; + cx.notify(); + this.exportTask = 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; + this.exportTask = null; + if (failure) { + notify({ title: "Could not export Insights", body: sanitizeError(failure), level: "error" }); + } else if (savedPath) { + notify({ title: "Insights exported", body: savedPath, level: "success" }); + } + exportCx.notify(); + } + } + }); + } + applySnapshot(payload, cx) { if (!isPresentation(payload)) throw new Error("The presentation helper returned an unexpected dashboard payload."); const snapshot = payload.snapshot; @@ -1461,6 +1515,10 @@ export default class Insights extends View { disabled: this.busy(), variant: "outline", })) + .child(button("insights-export", this.exporting ? "Exporting…" : "Export", (_event, clickCx) => this.exportMarkdown(clickCx), c, { + disabled: !this.exportReady(), + variant: "outline", + })) .when(Boolean(this.snapshot), (el) => el.child(button( "insights-settings", this.showSettings ? "Dashboard" : "Settings", 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); } diff --git a/tests/herdr-insights/analytics-ui.test.mjs b/tests/herdr-insights/analytics-ui.test.mjs index f919363..7027bdc 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); @@ -122,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"); @@ -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,9 @@ function blankInstance() { instance.years = [2024]; instance.dashboard = null; instance.dashboardKey = null; + instance.exporting = false; + instance.exportTask = null; + instance.closed = false; instance.dashboardPending = null; instance.dashboardError = null; instance.dashboardTimer = null; @@ -328,6 +378,90 @@ 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", + })); + instance.dashboardKey = presentationKey(instance.snapshot, instance.dashboardOptions()); + 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); + + 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.dashboardCurrent = () => true; + let exported = false; + instance.exportMarkdown = () => { exported = 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); +}); + 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 +682,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('"insights-export"', headerAt); + const settingsAt = mainSource.indexOf('"insights-settings"', headerAt); + assert.ok(headerAt > 0 && refreshAt > headerAt && exportAt > refreshAt && settingsAt > exportAt, + "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/); }); 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);