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.subtitle")} +
+