Skip to content

Commit 2107b30

Browse files
ofershapcursoragent
andcommitted
feat: badge filtering, context badges, version drilldown, and landing page link
- Add context badges (long/short sessions) based on avg cache_read_tokens - Badge filter dropdown on members table with auto-sort by relevant column - Client versions section with expandable user lists per version - Insights page: plan exhaustion data, command adoption charts - Spend trend chart improvements and daily spend chart refinements - User detail page enhancements (model cost breakdown, tools & features) - Add homepage link to package.json and README navigation row Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 8b1665e commit 2107b30

10 files changed

Lines changed: 794 additions & 93 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
"name": "cursor-usage-tracker",
33
"version": "0.0.0-development",
44
"description": "Open-source Cursor IDE usage monitoring, anomaly detection, and alerting for enterprise teams",
5+
"homepage": "https://cursor-usage-tracker.sticklight.app",
56
"type": "module",
67
"scripts": {
78
"dev": "next dev",

src/app/api/analytics/route.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
getAnalyticsCommandsSummary,
1010
getAnalyticsFileExtensionsSummary,
1111
getAnalyticsClientVersionsSummary,
12+
getUsersByClientVersion,
1213
getPlanExhaustionStats,
1314
} from "@/lib/db";
1415

@@ -29,6 +30,7 @@ export function GET(request: Request) {
2930
commands: getAnalyticsCommandsSummary(days),
3031
fileExtensions: getAnalyticsFileExtensionsSummary(days),
3132
clientVersions: getAnalyticsClientVersionsSummary(),
33+
versionUsers: getUsersByClientVersion(),
3234
planExhaustion: getPlanExhaustionStats(),
3335
});
3436
} catch {

src/app/dashboard-client.tsx

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ function fmt(n: number): string {
4545
return n.toLocaleString();
4646
}
4747

48-
export type SortColumn = "spend" | "activity" | "reqs" | "lines" | "cpr" | "name";
48+
export type SortColumn = "spend" | "activity" | "reqs" | "lines" | "cpr" | "name" | "context";
4949

5050
interface SpendBreakdownRow {
5151
date: string;
@@ -72,6 +72,8 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
7272
const [loading, setLoading] = useState(false);
7373
const [sortCol, setSortCol] = useState<SortColumn>("spend");
7474
const [sortAsc, setSortAsc] = useState(false);
75+
const [badgeFilter, setBadgeFilter] = useState<string | null>(null);
76+
const [preBadgeSortCol, setPreBadgeSortCol] = useState<SortColumn | null>(null);
7577
const [groups, setGroups] = useState<BillingGroupWithMembers[]>([]);
7678
const [selectedGroup, setSelectedGroup] = useState("all");
7779

@@ -133,6 +135,36 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
133135
});
134136
}, []);
135137

138+
const BADGE_SORT_MAP: Record<string, SortColumn> = {
139+
"long-sessions": "context",
140+
"short-sessions": "context",
141+
"over-budget": "spend",
142+
"premium-model": "cpr",
143+
"cost-efficient": "cpr",
144+
"power-user": "reqs",
145+
"tab-completer": "reqs",
146+
"deep-thinker": "spend",
147+
"light-user": "reqs",
148+
balanced: "reqs",
149+
};
150+
151+
const handleBadgeFilter = useCallback(
152+
(badge: string | null) => {
153+
if (badge) {
154+
setPreBadgeSortCol(sortCol);
155+
const targetSort = BADGE_SORT_MAP[badge] ?? "spend";
156+
setSortCol(targetSort);
157+
setSortAsc(badge === "short-sessions" || badge === "cost-efficient");
158+
} else if (preBadgeSortCol) {
159+
setSortCol(preBadgeSortCol);
160+
setSortAsc(false);
161+
setPreBadgeSortCol(null);
162+
}
163+
setBadgeFilter(badge);
164+
},
165+
[sortCol, preBadgeSortCol],
166+
);
167+
136168
const filteredUsers = useMemo(() => {
137169
let users = stats.rankedUsers;
138170
if (groupEmailSet) {
@@ -144,6 +176,14 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
144176
(u) => u.email.toLowerCase().includes(q) || u.name.toLowerCase().includes(q),
145177
);
146178
}
179+
if (badgeFilter) {
180+
users = users.filter(
181+
(u) =>
182+
u.usage_badge === badgeFilter ||
183+
u.spend_badge === badgeFilter ||
184+
u.context_badge === badgeFilter,
185+
);
186+
}
147187
const sorted = [...users].sort((a, b) => {
148188
const flip = sortAsc ? -1 : 1;
149189
switch (sortCol) {
@@ -162,12 +202,14 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
162202
const cprB = b.agent_requests > 0 ? b.spend_cents / b.agent_requests : 0;
163203
return (cprB - cprA) * flip;
164204
}
205+
case "context":
206+
return (b.avg_cache_read - a.avg_cache_read) * flip;
165207
default:
166208
return 0;
167209
}
168210
});
169211
return sorted;
170-
}, [stats.rankedUsers, search, sortCol, sortAsc, groupEmailSet]);
212+
}, [stats.rankedUsers, search, sortCol, sortAsc, groupEmailSet, badgeFilter]);
171213

172214
const searchedUser = useMemo(() => {
173215
if (!search.trim()) return null;
@@ -193,7 +235,7 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
193235
}
194236

195237
const timeLabel = formatTimeLabel(days);
196-
const isSearching = search.trim().length > 0 || selectedGroup !== "all";
238+
const isSearching = search.trim().length > 0 || selectedGroup !== "all" || badgeFilter !== null;
197239
const totalLines = stats.dailyTeamActivity.reduce((s, d) => s + d.total_lines_added, 0);
198240
const effectiveDays = Math.min(days, stats.cycleDays);
199241
const cycleStartDate = new Date(stats.cycleStart);
@@ -387,14 +429,18 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
387429

388430
{/* ── Row: Team Spend Trend + Model Cost Comparison ── */}
389431
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
390-
<SpendTrendChart data={filteredTeamDailySpend} selectedDays={days} />
432+
<SpendTrendChart
433+
data={filteredTeamDailySpend}
434+
selectedDays={days}
435+
avgPerDay={filteredSpendCents / 100 / (effectiveDays || 1)}
436+
/>
391437
<ModelCostComparison data={data.modelCosts} />
392438
</div>
393439

394440
{/* ── Charts ── */}
395441
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
396442
<SpendBarChart data={filteredUsers.slice(0, 20)} highlightEmail={searchedUser?.email} />
397-
<DailySpendChart data={dailySpendData} />
443+
<DailySpendChart data={dailySpendData} onNameClick={(name) => setSearch(name)} />
398444
</div>
399445

400446
{/* ── Table ── */}
@@ -405,6 +451,8 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
405451
onSort={handleSort}
406452
highlightEmail={searchedUser?.email}
407453
timeLabel={timeLabel}
454+
badgeFilter={badgeFilter}
455+
onBadgeFilter={handleBadgeFilter}
408456
/>
409457
</div>
410458
);

src/app/insights/insights-client.tsx

Lines changed: 128 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"use client";
22

3-
import { useMemo, useState } from "react";
3+
import React, { useMemo, useState } from "react";
44
import {
55
AreaChart,
66
Area,
@@ -71,6 +71,8 @@ interface VersionEntry {
7171
percentage: number;
7272
}
7373

74+
type VersionUsers = Record<string, Array<{ email: string; name: string }>>;
75+
7476
interface ModelEfficiencyEntry {
7577
model: string;
7678
users: number;
@@ -113,6 +115,7 @@ interface InsightsData {
113115
commands: CommandsEntry[];
114116
fileExtensions: FileExtEntry[];
115117
clientVersions: VersionEntry[];
118+
versionUsers: VersionUsers;
116119
modelEfficiency: ModelEfficiencyEntry[];
117120
planExhaustion: PlanExhaustionData;
118121
}
@@ -484,65 +487,134 @@ export function InsightsClient({ data }: { data: InsightsData }) {
484487
</div>
485488

486489
{/* Client Versions - compact */}
487-
<ChartCard title="Client Versions (Latest Day)">
488-
<div className="flex gap-4">
489-
<ResponsiveContainer width={160} height={160}>
490-
<PieChart>
491-
<Pie
492-
data={data.clientVersions.slice(0, 6)}
493-
dataKey="user_count"
494-
nameKey="version"
495-
cx="50%"
496-
cy="50%"
497-
outerRadius={55}
498-
innerRadius={25}
499-
paddingAngle={2}
500-
>
501-
{data.clientVersions.slice(0, 6).map((_, i) => (
502-
<Cell key={i} fill={COLORS[i % COLORS.length]} />
503-
))}
504-
</Pie>
505-
<Tooltip
506-
contentStyle={{
507-
backgroundColor: "#18181b",
508-
border: "1px solid #3f3f46",
509-
borderRadius: "6px",
510-
fontSize: "11px",
511-
}}
512-
formatter={
513-
((v: number, _: string, entry: { payload: VersionEntry }) => [
514-
`${v ?? 0} users (${entry.payload.percentage.toFixed(0)}%)`,
515-
entry.payload.version,
516-
]) as never
517-
}
518-
/>
519-
</PieChart>
520-
</ResponsiveContainer>
521-
<div className="flex-1 overflow-y-auto max-h-[160px]">
522-
<table className="w-full text-xs">
523-
<tbody>
524-
{data.clientVersions.map((v, i) => (
525-
<tr key={v.version} className="border-b border-zinc-800/30">
526-
<td className="py-1">
527-
<span
528-
className="inline-block w-2 h-2 rounded-full mr-1.5"
529-
style={{ backgroundColor: COLORS[i % COLORS.length] }}
530-
/>
531-
<span className="font-mono text-zinc-300">{v.version}</span>
532-
</td>
533-
<td className="text-right py-1 text-zinc-400">{v.user_count}</td>
534-
<td className="text-right py-1 text-zinc-500">{v.percentage.toFixed(0)}%</td>
535-
</tr>
536-
))}
537-
</tbody>
538-
</table>
539-
</div>
540-
</div>
541-
</ChartCard>
490+
<ClientVersionsSection
491+
clientVersions={data.clientVersions}
492+
versionUsers={data.versionUsers}
493+
/>
542494
</div>
543495
);
544496
}
545497

498+
function ClientVersionsSection({
499+
versionUsers,
500+
}: {
501+
clientVersions: VersionEntry[];
502+
versionUsers: VersionUsers;
503+
}) {
504+
const [expandedVersion, setExpandedVersion] = useState<string | null>(null);
505+
506+
const versions = useMemo(() => {
507+
const totalUsers = Object.values(versionUsers).reduce((sum, u) => sum + u.length, 0);
508+
return Object.entries(versionUsers)
509+
.map(([version, users]) => ({
510+
version,
511+
user_count: users.length,
512+
percentage: totalUsers > 0 ? (users.length / totalUsers) * 100 : 0,
513+
users,
514+
}))
515+
.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }));
516+
}, [versionUsers]);
517+
518+
const latestVersion = versions[0]?.version;
519+
520+
return (
521+
<ChartCard title="Client Versions">
522+
<div className="flex gap-4">
523+
<ResponsiveContainer width={160} height={160}>
524+
<PieChart>
525+
<Pie
526+
data={versions.slice(0, 6)}
527+
dataKey="user_count"
528+
nameKey="version"
529+
cx="50%"
530+
cy="50%"
531+
outerRadius={55}
532+
innerRadius={25}
533+
paddingAngle={2}
534+
>
535+
{versions.slice(0, 6).map((_, i) => (
536+
<Cell key={i} fill={COLORS[i % COLORS.length]} />
537+
))}
538+
</Pie>
539+
<Tooltip
540+
contentStyle={{
541+
backgroundColor: "#18181b",
542+
border: "1px solid #3f3f46",
543+
borderRadius: "6px",
544+
fontSize: "11px",
545+
}}
546+
formatter={
547+
((v: number, _: string, entry: { payload: (typeof versions)[number] }) => [
548+
`${v ?? 0} users (${entry.payload.percentage.toFixed(0)}%)`,
549+
entry.payload.version,
550+
]) as never
551+
}
552+
/>
553+
</PieChart>
554+
</ResponsiveContainer>
555+
<div className="flex-1 overflow-y-auto max-h-[400px]">
556+
<table className="w-full text-xs">
557+
<tbody>
558+
{versions.map((v, i) => {
559+
const isExpanded = expandedVersion === v.version;
560+
const isLatest = v.version === latestVersion;
561+
return (
562+
<React.Fragment key={v.version}>
563+
<tr
564+
className={`border-b border-zinc-800/30 cursor-pointer hover:bg-zinc-800/40 transition-colors ${isExpanded ? "bg-zinc-800/30" : ""}`}
565+
onClick={() => setExpandedVersion(isExpanded ? null : v.version)}
566+
>
567+
<td className="py-1">
568+
<span
569+
className="inline-block w-2 h-2 rounded-full mr-1.5"
570+
style={{ backgroundColor: COLORS[i % COLORS.length] }}
571+
/>
572+
<span className="font-mono text-zinc-300">{v.version}</span>
573+
{isLatest && (
574+
<span className="ml-1.5 text-[10px] text-emerald-400 font-medium">
575+
latest
576+
</span>
577+
)}
578+
</td>
579+
<td className="text-right py-1 text-zinc-400">{v.user_count}</td>
580+
<td className="text-right py-1 text-zinc-500">{v.percentage.toFixed(0)}%</td>
581+
<td className="text-right py-1 pl-1 text-zinc-600 w-4">
582+
{v.users.length > 0 && (isExpanded ? "▾" : "▸")}
583+
</td>
584+
</tr>
585+
{isExpanded && v.users.length > 0 && (
586+
<tr>
587+
<td colSpan={4} className="pb-1">
588+
<div className="pl-5 py-1 space-y-0.5">
589+
{v.users.map((u) => (
590+
<a
591+
key={u.email}
592+
href={`/users/${encodeURIComponent(u.email)}`}
593+
className="block text-[11px] text-zinc-400 hover:text-blue-400 transition-colors"
594+
>
595+
{u.name}
596+
{!isLatest && (
597+
<span className="ml-1 text-amber-500/60 text-[10px]">
598+
needs update
599+
</span>
600+
)}
601+
</a>
602+
))}
603+
</div>
604+
</td>
605+
</tr>
606+
)}
607+
</React.Fragment>
608+
);
609+
})}
610+
</tbody>
611+
</table>
612+
</div>
613+
</div>
614+
</ChartCard>
615+
);
616+
}
617+
546618
function PlanExhaustionSection({ data }: { data: PlanExhaustionData }) {
547619
const [showUsers, setShowUsers] = useState(false);
548620
const { summary, users } = data;

src/app/insights/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
getAnalyticsCommandsSummary,
99
getAnalyticsFileExtensionsSummary,
1010
getAnalyticsClientVersionsSummary,
11+
getUsersByClientVersion,
1112
getModelEfficiency,
1213
getPlanExhaustionStats,
1314
} from "@/lib/db";
@@ -27,6 +28,7 @@ export default function InsightsPage() {
2728
commands: getAnalyticsCommandsSummary(30),
2829
fileExtensions: getAnalyticsFileExtensionsSummary(30),
2930
clientVersions: getAnalyticsClientVersionsSummary(),
31+
versionUsers: getUsersByClientVersion(),
3032
modelEfficiency: getModelEfficiency(),
3133
planExhaustion: getPlanExhaustionStats(),
3234
};

0 commit comments

Comments
 (0)