Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions dashboard/src/content/copy.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 46 additions & 0 deletions dashboard/src/hooks/use-change-timeline.js
Original file line number Diff line number Diff line change
@@ -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 };
}
9 changes: 9 additions & 0 deletions dashboard/src/pages/DashboardPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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}
Expand Down
91 changes: 91 additions & 0 deletions dashboard/src/ui/matrix-a/components/ChangeTimelineCard.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={`rounded-xl border border-oai-gray-200 dark:border-oai-gray-800 bg-white dark:bg-oai-gray-900 p-5 ${className}`}>
<div className="mb-4">
<h3 className="text-sm font-medium text-oai-gray-500 dark:text-oai-gray-300 uppercase tracking-wide">
{copy("dashboard.timeline.title")}
</h3>
<p className="mt-1 text-xs text-oai-gray-500 dark:text-oai-gray-400">
{copy("dashboard.timeline.subtitle")}
</p>
</div>

<TimelineBody events={events} loading={loading} error={error} />
</div>
);
}

function TimelineBody({ events, loading, error }) {
if (error) {
return (
<div className="text-sm text-red-500 dark:text-red-400" role="alert">
{copy("dashboard.timeline.error")}
</div>
);
}
if (loading && events.length === 0) {
return (
<div className="text-sm text-oai-gray-500 dark:text-oai-gray-400">
{copy("dashboard.timeline.loading")}
</div>
);
}
if (events.length === 0) {
return (
<div className="text-sm text-oai-gray-500 dark:text-oai-gray-400">
{copy("dashboard.timeline.empty")}
</div>
);
}
return (
<div className="space-y-3">
{events.map((event, index) => (
<div key={eventKey(event, index)} className="flex items-start gap-3">
<div className="mt-1 h-2.5 w-2.5 rounded-full bg-oai-brand" />
<div className="min-w-0">
<div className="text-xs text-oai-gray-500 dark:text-oai-gray-400">{event.date}</div>
<div className="text-sm font-medium text-oai-black dark:text-oai-white">
{eventTitle(event)}
</div>
<div className="text-xs text-oai-gray-500 dark:text-oai-gray-400">
{eventDetail(event)}
</div>
</div>
</div>
))}
</div>
);
}
12 changes: 12 additions & 0 deletions dashboard/src/ui/matrix-a/views/DashboardView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,6 +28,9 @@ export function DashboardView(props) {
projectUsageEntries,
projectUsageLimit,
setProjectUsageLimit,
changeTimelineEvents,
changeTimelineLoading,
changeTimelineError,
topModels,
signedIn,
publicMode,
Expand Down Expand Up @@ -159,6 +163,14 @@ export function DashboardView(props) {

{isLocalMode ? <WidgetOnboardingCard /> : null}

{!screenshotMode ? (
<ChangeTimelineCard
events={changeTimelineEvents}
loading={changeTimelineLoading}
error={changeTimelineError}
/>
) : null}

{shouldShowInstall ? (
<FadeIn delay={0.25}>
<div className="rounded-xl border border-oai-gray-200 dark:border-oai-gray-800 bg-white dark:bg-oai-gray-900 p-3">
Expand Down
75 changes: 75 additions & 0 deletions src/lib/local-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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, {
Expand Down
Loading