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/);
+});