From 26437c1d0f921b1d44e99f86936f7d815778bb43 Mon Sep 17 00:00:00 2001 From: Eric Grill <694055+EricGrill@users.noreply.github.com> Date: Mon, 20 Apr 2026 13:59:45 -0600 Subject: [PATCH] feat: add a dashboard timeline of provider and config changes This adds a local change timeline that surfaces first-seen providers, first-seen models, project attribution activation, and cloud sync configuration as a compact dashboard card. The goal is to make spikes more explainable without forcing users to infer what changed from raw charts alone. Constraint: Reuse local tracker data and avoid introducing a new persistent event log for the first timeline version Rejected: Build a full event-sourcing layer before shipping the timeline card | too much infrastructure for a first explanatory timeline Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep timeline events factual and derived from observable local state; do not infer speculative changes that the app cannot justify from data Tested: node --test test/change-timeline.test.js Tested: npm run validate:copy Not-tested: Full npm test (workspace still lacks root dev modules such as esbuild and @sourcegraph/scip-typescript) Not-tested: Dashboard runtime rendering in a browser session Related: #22 --- dashboard/src/content/copy.csv | 15 +++ dashboard/src/hooks/use-change-timeline.js | 46 +++++++++ dashboard/src/pages/DashboardPage.jsx | 9 ++ .../components/ChangeTimelineCard.jsx | 91 ++++++++++++++++++ .../src/ui/matrix-a/views/DashboardView.jsx | 12 +++ src/lib/local-api.js | 75 +++++++++++++++ test/change-timeline.test.js | 96 +++++++++++++++++++ 7 files changed, 344 insertions(+) create mode 100644 dashboard/src/hooks/use-change-timeline.js create mode 100644 dashboard/src/ui/matrix-a/components/ChangeTimelineCard.jsx create mode 100644 test/change-timeline.test.js diff --git a/dashboard/src/content/copy.csv b/dashboard/src/content/copy.csv index 69e8247..262a5e3 100644 --- a/dashboard/src/content/copy.csv +++ b/dashboard/src/content/copy.csv @@ -202,6 +202,21 @@ dashboard.rolling.title,ui,RollingUsagePanel,RollingUsagePanel,title,"Recent Act dashboard.rolling.last_7d,ui,RollingUsagePanel,RollingUsagePanel,label_last_7d,"Last 7 days",,active dashboard.rolling.last_30d,ui,RollingUsagePanel,RollingUsagePanel,label_last_30d,"Last 30 days",,active dashboard.rolling.avg_active_day,ui,RollingUsagePanel,RollingUsagePanel,label_avg_active_day,"Daily average",,active +dashboard.timeline.title,ui,ChangeTimelineCard,ChangeTimelineCard,title,"What Changed",,active +dashboard.timeline.subtitle,ui,ChangeTimelineCard,ChangeTimelineCard,subtitle,"A timeline of provider, model, and config shifts",,active +dashboard.timeline.empty,ui,ChangeTimelineCard,ChangeTimelineCard,empty,"No notable changes yet",,active +dashboard.timeline.loading,ui,ChangeTimelineCard,ChangeTimelineCard,loading,"Loading…",,active +dashboard.timeline.error,ui,ChangeTimelineCard,ChangeTimelineCard,error,"Could not load the change timeline.",,active +dashboard.timeline.event.source_first_seen.title,ui,ChangeTimelineCard,ChangeTimelineCard,event_source_title,"First saw {{source}}",,active +dashboard.timeline.event.source_first_seen.detail,ui,ChangeTimelineCard,ChangeTimelineCard,event_source_detail,"New provider or CLI integration became active",,active +dashboard.timeline.event.model_first_seen.title,ui,ChangeTimelineCard,ChangeTimelineCard,event_model_title,"Started using {{model}}",,active +dashboard.timeline.event.model_first_seen.detail,ui,ChangeTimelineCard,ChangeTimelineCard,event_model_detail,"Model first appeared in tracked usage",,active +dashboard.timeline.event.project_attribution_started.title,ui,ChangeTimelineCard,ChangeTimelineCard,event_project_title,"Project attribution active",,active +dashboard.timeline.event.project_attribution_started.detail,ui,ChangeTimelineCard,ChangeTimelineCard,event_project_detail,"Per-project usage started appearing in the dashboard",,active +dashboard.timeline.event.cloud_sync_configured.title,ui,ChangeTimelineCard,ChangeTimelineCard,event_cloud_title,"Cloud sync configured",,active +dashboard.timeline.event.cloud_sync_configured.detail,ui,ChangeTimelineCard,ChangeTimelineCard,event_cloud_detail,"A device token/config was saved for uploading usage",,active +dashboard.timeline.event.unknown.title,ui,ChangeTimelineCard,ChangeTimelineCard,event_unknown_title,"New change",,active +dashboard.timeline.event.unknown.detail,ui,ChangeTimelineCard,ChangeTimelineCard,event_unknown_detail,"This event type is not yet recognized by the dashboard",,active dashboard.model_breakdown.title,ui,NeuralDivergenceMap,NeuralDivergenceMap,title,"Model Breakdown",,active dashboard.model_breakdown.footer,ui,NeuralDivergenceMap,NeuralDivergenceMap,footer,"Multi-Engine Load Balancing // Active Session",,active diff --git a/dashboard/src/hooks/use-change-timeline.js b/dashboard/src/hooks/use-change-timeline.js new file mode 100644 index 0000000..06ce267 --- /dev/null +++ b/dashboard/src/hooks/use-change-timeline.js @@ -0,0 +1,46 @@ +import { useCallback, useEffect, useState } from "react"; +import { isMockEnabled } from "../lib/mock-data"; + +const MOCK_EVENTS = [ + { date: "2026-04-18", event_type: "source_first_seen", params: { source: "gemini" } }, + { date: "2026-04-20", event_type: "cloud_sync_configured", params: {} }, +]; + +export function useChangeTimeline() { + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const mockEnabled = isMockEnabled(); + + const refresh = useCallback(async () => { + if (mockEnabled) { + setEvents(MOCK_EVENTS); + setError(null); + return; + } + setLoading(true); + setError(null); + try { + const res = await fetch("/functions/tokentracker-change-timeline", { + headers: { Accept: "application/json" }, + cache: "no-store", + }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const data = await res.json(); + setEvents(Array.isArray(data?.events) ? data.events : []); + } catch (err) { + setEvents([]); + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + setLoading(false); + } + }, [mockEnabled]); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { events, loading, error, refresh }; +} diff --git a/dashboard/src/pages/DashboardPage.jsx b/dashboard/src/pages/DashboardPage.jsx index 0b48d19..3d07089 100644 --- a/dashboard/src/pages/DashboardPage.jsx +++ b/dashboard/src/pages/DashboardPage.jsx @@ -5,6 +5,7 @@ import { useTrendData } from "../hooks/use-trend-data.js"; import { useUsageData } from "../hooks/use-usage-data.js"; import { useUsageLimits } from "../hooks/use-usage-limits.js"; import { useUsageModelBreakdown } from "../hooks/use-usage-model-breakdown.js"; +import { useChangeTimeline } from "../hooks/use-change-timeline.js"; import { isAccessTokenReady, normalizeAccessToken, @@ -447,6 +448,11 @@ export function DashboardPage({ timeZone, tzOffsetMinutes, }); + const { + events: changeTimelineEvents, + loading: changeTimelineLoading, + error: changeTimelineError, + } = useChangeTimeline(); const shareDailyToTrend = period === "week" || period === "month"; const useDailyTrend = period === "week" || period === "month"; @@ -1208,6 +1214,9 @@ export function DashboardPage({ projectUsageEntries={projectUsageEntries} projectUsageLimit={projectUsageLimit} setProjectUsageLimit={setProjectUsageLimit} + changeTimelineEvents={changeTimelineEvents} + changeTimelineLoading={changeTimelineLoading} + changeTimelineError={changeTimelineError} topModels={topModels} signedIn={signedIn} publicMode={publicMode} diff --git a/dashboard/src/ui/matrix-a/components/ChangeTimelineCard.jsx b/dashboard/src/ui/matrix-a/components/ChangeTimelineCard.jsx new file mode 100644 index 0000000..bc92fc1 --- /dev/null +++ b/dashboard/src/ui/matrix-a/components/ChangeTimelineCard.jsx @@ -0,0 +1,91 @@ +import React from "react"; +import { copy } from "../../../lib/copy"; + +const SUPPORTED_EVENT_TYPES = new Set([ + "source_first_seen", + "model_first_seen", + "project_attribution_started", + "cloud_sync_configured", +]); + +function eventTitle(event) { + if (!event || !SUPPORTED_EVENT_TYPES.has(event.event_type)) { + return copy("dashboard.timeline.event.unknown.title"); + } + return copy(`dashboard.timeline.event.${event.event_type}.title`, event.params || {}); +} + +function eventDetail(event) { + if (!event || !SUPPORTED_EVENT_TYPES.has(event.event_type)) { + return copy("dashboard.timeline.event.unknown.detail"); + } + return copy(`dashboard.timeline.event.${event.event_type}.detail`, event.params || {}); +} + +function eventKey(event, index) { + const params = event?.params || {}; + const paramSig = Object.keys(params) + .sort() + .map((k) => `${k}=${params[k]}`) + .join("&"); + return `${event?.event_type || "unknown"}-${event?.date || index}-${paramSig}`; +} + +export function ChangeTimelineCard({ events = [], loading = false, error = null, className = "" }) { + return ( +
+
+

+ {copy("dashboard.timeline.title")} +

+

+ {copy("dashboard.timeline.subtitle")} +

+
+ + +
+ ); +} + +function TimelineBody({ events, loading, error }) { + if (error) { + return ( +
+ {copy("dashboard.timeline.error")} +
+ ); + } + if (loading && events.length === 0) { + return ( +
+ {copy("dashboard.timeline.loading")} +
+ ); + } + if (events.length === 0) { + return ( +
+ {copy("dashboard.timeline.empty")} +
+ ); + } + return ( +
+ {events.map((event, index) => ( +
+
+
+
{event.date}
+
+ {eventTitle(event)} +
+
+ {eventDetail(event)} +
+
+
+ ))} +
+ ); +} diff --git a/dashboard/src/ui/matrix-a/views/DashboardView.jsx b/dashboard/src/ui/matrix-a/views/DashboardView.jsx index 4282f32..0240792 100644 --- a/dashboard/src/ui/matrix-a/views/DashboardView.jsx +++ b/dashboard/src/ui/matrix-a/views/DashboardView.jsx @@ -6,6 +6,7 @@ import { DataDetails } from "../components/DataDetails.jsx"; import { StatsPanel } from "../components/StatsPanel.jsx"; import { UsageOverview } from "../components/UsageOverview.jsx"; import { TrendMonitor } from "../components/TrendMonitor.jsx"; +import { ChangeTimelineCard } from "../components/ChangeTimelineCard.jsx"; import { FadeIn } from "../../foundation/FadeIn.jsx"; import { MacAppBanner } from "../components/MacAppBanner.jsx"; import { WidgetOnboardingCard } from "../components/WidgetOnboardingCard.jsx"; @@ -27,6 +28,9 @@ export function DashboardView(props) { projectUsageEntries, projectUsageLimit, setProjectUsageLimit, + changeTimelineEvents, + changeTimelineLoading, + changeTimelineError, topModels, signedIn, publicMode, @@ -159,6 +163,14 @@ export function DashboardView(props) { {isLocalMode ? : null} + {!screenshotMode ? ( + + ) : null} + {shouldShowInstall ? (
diff --git a/src/lib/local-api.js b/src/lib/local-api.js index bdebba3..1b4133a 100644 --- a/src/lib/local-api.js +++ b/src/lib/local-api.js @@ -377,6 +377,67 @@ function aggregateHourlyByDay(rows, dayKey, timeZoneContext) { return Array.from(byHour.values()).sort((a, b) => a.hour.localeCompare(b.hour)); } +function buildChangeTimeline({ rows, projectRows, configMtime }) { + const events = []; + const seenSources = new Map(); + const seenModels = new Map(); + + for (const row of rows) { + const when = typeof row?.hour_start === "string" ? row.hour_start : null; + if (!when) continue; + const source = typeof row?.source === "string" ? row.source : null; + const model = typeof row?.model === "string" ? row.model : null; + + if (source && !seenSources.has(source)) { + seenSources.set(source, when); + } + if (model && !seenModels.has(model)) { + seenModels.set(model, when); + } + } + + for (const [source, when] of seenSources.entries()) { + events.push({ + date: when.slice(0, 10), + event_type: "source_first_seen", + params: { source }, + }); + } + + for (const [model, when] of Array.from(seenModels.entries()).slice(0, 8)) { + events.push({ + date: when.slice(0, 10), + event_type: "model_first_seen", + params: { model }, + }); + } + + if (projectRows.length > 0) { + const firstProjectRow = projectRows + .filter((row) => typeof row?.hour_start === "string") + .sort((a, b) => String(a.hour_start).localeCompare(String(b.hour_start)))[0]; + if (firstProjectRow?.hour_start) { + events.push({ + date: firstProjectRow.hour_start.slice(0, 10), + event_type: "project_attribution_started", + params: {}, + }); + } + } + + if (configMtime) { + events.push({ + date: configMtime.slice(0, 10), + event_type: "cloud_sync_configured", + params: {}, + }); + } + + return events + .sort((a, b) => String(a.date).localeCompare(String(b.date))) + .slice(-12); +} + // --------------------------------------------------------------------------- // Sync helper // --------------------------------------------------------------------------- @@ -1153,6 +1214,20 @@ function createLocalApiHandler({ queuePath }) { return true; } + if (p === "/functions/tokentracker-change-timeline") { + const rows = readQueueData(qp); + const projectQueuePath = path.join(path.dirname(qp), "project.queue.jsonl"); + const projectRows = readProjectQueueData(projectQueuePath); + const configPath = path.join(os.homedir(), ".tokentracker", "tracker", "config.json"); + const configStat = fs.statSync(configPath, { throwIfNoEntry: false }); + const configMtime = configStat?.mtime ? new Date(configStat.mtime).toISOString() : null; + json(res, { + generated_at: new Date().toISOString(), + events: buildChangeTimeline({ rows, projectRows, configMtime }), + }); + return true; + } + // --- user-status (stub) --- if (p === "/functions/tokentracker-user-status") { json(res, { diff --git a/test/change-timeline.test.js b/test/change-timeline.test.js new file mode 100644 index 0000000..78c2d3c --- /dev/null +++ b/test/change-timeline.test.js @@ -0,0 +1,96 @@ +const assert = require("node:assert/strict"); +const fs = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); +const { test } = require("node:test"); + +const { createLocalApiHandler } = require("../src/lib/local-api"); + +function createRequest({ method = "GET", headers = {} } = {}) { + return { + method, + headers, + async *[Symbol.asyncIterator]() {}, + }; +} + +function createResponse() { + return { + statusCode: null, + headers: null, + body: Buffer.alloc(0), + writeHead(statusCode, headers) { + this.statusCode = statusCode; + this.headers = headers; + }, + end(chunk) { + this.body = chunk ? Buffer.from(chunk) : Buffer.alloc(0); + }, + }; +} + +test("local timeline endpoint reports source, model, project, and config events", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "tokentracker-timeline-")); + const prevHome = process.env.HOME; + try { + process.env.HOME = tmp; + const trackerDir = path.join(tmp, ".tokentracker", "tracker"); + await fs.mkdir(trackerDir, { recursive: true }); + const queuePath = path.join(trackerDir, "queue.jsonl"); + const projectQueuePath = path.join(trackerDir, "project.queue.jsonl"); + const configPath = path.join(trackerDir, "config.json"); + + await fs.writeFile( + queuePath, + [ + JSON.stringify({ source: "codex", model: "gpt-5.4", hour_start: "2026-04-18T00:00:00.000Z" }), + JSON.stringify({ source: "gemini", model: "gemini-2.5-pro", hour_start: "2026-04-20T00:00:00.000Z" }), + ].join("\n"), + "utf8", + ); + await fs.writeFile( + projectQueuePath, + JSON.stringify({ project_key: "octo/api", source: "codex", hour_start: "2026-04-21T00:00:00.000Z" }), + "utf8", + ); + await fs.writeFile(configPath, JSON.stringify({ baseUrl: "https://example.invalid", deviceToken: "token" }), "utf8"); + + const handler = createLocalApiHandler({ queuePath }); + const req = createRequest(); + const res = createResponse(); + + const handled = await handler(req, res, new URL("http://127.0.0.1/functions/tokentracker-change-timeline")); + + assert.equal(handled, true); + assert.equal(res.statusCode, 200); + const payload = JSON.parse(res.body.toString("utf8")); + for (const event of payload.events) { + assert.ok(typeof event.event_type === "string", "event_type is a string"); + assert.ok(event.params !== null && typeof event.params === "object", "params is an object"); + assert.equal(event.title, undefined, "API must not embed pre-rendered titles"); + assert.equal(event.detail, undefined, "API must not embed pre-rendered details"); + } + const sourceEvents = payload.events.filter((e) => e.event_type === "source_first_seen"); + assert.deepEqual( + sourceEvents.map((e) => e.params.source).sort(), + ["codex", "gemini"], + ); + const modelEvents = payload.events.filter((e) => e.event_type === "model_first_seen"); + assert.ok(modelEvents.some((e) => e.params.model === "gpt-5.4")); + assert.ok(payload.events.some((e) => e.event_type === "project_attribution_started")); + assert.ok(payload.events.some((e) => e.event_type === "cloud_sync_configured")); + } finally { + if (prevHome === undefined) delete process.env.HOME; + else process.env.HOME = prevHome; + await fs.rm(tmp, { recursive: true, force: true }); + } +}); + +test("dashboard wires the change timeline card", () => { + const dashboardSrc = require("node:fs").readFileSync( + path.join(process.cwd(), "dashboard/src/ui/matrix-a/views/DashboardView.jsx"), + "utf8", + ); + assert.match(dashboardSrc, /ChangeTimelineCard/); + assert.match(dashboardSrc, /changeTimelineEvents/); +});