Skip to content

Commit 2e67254

Browse files
ofershapcursoragent
andcommitted
feat: per-user plan exhaustion KPI + clickable insights cards
Show plan exhaustion day on user detail page as a KPI card. Make plan exhaustion bucket cards on insights clickable to filter the dashboard members table by exhaustion day range. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 4da8930 commit 2e67254

4 files changed

Lines changed: 125 additions & 9 deletions

File tree

src/app/dashboard-client.tsx

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,8 +70,13 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
7070
const [preBadgeSortCol, setPreBadgeSortCol] = useState<SortColumn | null>(null);
7171
const [groups, setGroups] = useState<BillingGroupWithMembers[]>([]);
7272
const [selectedGroup, setSelectedGroup] = useState("all");
73+
const [exhaustionFilter, setExhaustionFilter] = useState<{
74+
label: string;
75+
emails: Set<string>;
76+
} | null>(null);
7377

7478
const groupParam = searchParams.get("group");
79+
const exhaustionParam = searchParams.get("exhaustion");
7580
useEffect(() => {
7681
fetch("/api/groups")
7782
.then((r) => r.json())
@@ -82,6 +87,27 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
8287
}
8388
})
8489
.catch(() => {});
90+
if (exhaustionParam) {
91+
const [minStr, maxStr] = exhaustionParam.split("-");
92+
const min = parseInt(minStr ?? "0", 10);
93+
const max = parseInt(maxStr ?? "999", 10);
94+
fetch("/api/analytics")
95+
.then((r) => r.json())
96+
.then(
97+
(analytics: {
98+
planExhaustion?: { users: Array<{ email: string; days_to_exhaust: number }> };
99+
}) => {
100+
const matching = (analytics.planExhaustion?.users ?? []).filter(
101+
(u) => u.days_to_exhaust >= min && u.days_to_exhaust <= max,
102+
);
103+
setExhaustionFilter({
104+
label: `Plan exhausted day ${min}${max}`,
105+
emails: new Set(matching.map((u) => u.email)),
106+
});
107+
},
108+
)
109+
.catch(() => {});
110+
}
85111
const saved = localStorage.getItem("dashboard-days");
86112
if (saved) {
87113
const parsed = parseInt(saved, 10);
@@ -170,6 +196,9 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
170196
if (groupEmailSet) {
171197
users = users.filter((u) => groupEmailSet.has(u.email));
172198
}
199+
if (exhaustionFilter) {
200+
users = users.filter((u) => exhaustionFilter.emails.has(u.email));
201+
}
173202
if (search.trim()) {
174203
const q = search.toLowerCase();
175204
users = users.filter(
@@ -210,7 +239,7 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
210239
}
211240
});
212241
return sorted;
213-
}, [stats.rankedUsers, search, sortCol, sortAsc, groupEmailSet, badgeFilter]);
242+
}, [stats.rankedUsers, search, sortCol, sortAsc, groupEmailSet, badgeFilter, exhaustionFilter]);
214243

215244
const searchedUser = useMemo(() => {
216245
if (!search.trim()) return null;
@@ -236,7 +265,11 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
236265
}
237266

238267
const timeLabel = formatTimeLabel(days);
239-
const isSearching = search.trim().length > 0 || selectedGroup !== "all" || badgeFilter !== null;
268+
const isSearching =
269+
search.trim().length > 0 ||
270+
selectedGroup !== "all" ||
271+
badgeFilter !== null ||
272+
exhaustionFilter !== null;
240273
const totalLines = stats.dailyTeamActivity.reduce((s, d) => s + d.total_lines_added, 0);
241274
const effectiveDays = Math.min(days, stats.cycleDays);
242275
const cycleStartDate = new Date(stats.cycleStart);
@@ -361,6 +394,20 @@ export function DashboardClient({ initialData }: DashboardClientProps) {
361394
))}
362395
</div>
363396
{loading && <span className="text-[11px] text-zinc-500 animate-pulse">Updating...</span>}
397+
{exhaustionFilter && (
398+
<span className="inline-flex items-center gap-1.5 bg-orange-600/20 text-orange-300 rounded-md px-2 py-1 text-[11px] font-medium">
399+
{exhaustionFilter.label} ({exhaustionFilter.emails.size})
400+
<button
401+
onClick={() => {
402+
setExhaustionFilter(null);
403+
window.history.replaceState({}, "", "/");
404+
}}
405+
className="hover:text-orange-100 cursor-pointer"
406+
>
407+
408+
</button>
409+
</span>
410+
)}
364411
<div className="ml-auto text-[11px] text-zinc-600">
365412
{isSearching ? `${filteredUsers.length} / ` : ""}
366413
{stats.totalMembers} members

src/app/insights/insights-client.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -665,14 +665,17 @@ function PlanExhaustionSection({ data }: { data: PlanExhaustionData }) {
665665
label="Exceeded"
666666
value={`${summary.users_exhausted}/${summary.total_active}`}
667667
sub={`${summary.pct_exhausted}% of active`}
668+
href="/?exhaustion=1-999"
668669
/>
669670
<MiniKpi label="Avg Days" value={summary.avg_days.toFixed(1)} sub="to exceed" />
670671
<MiniKpi label="Median" value={summary.median_days.toString()} sub="days" />
671672
<div className="w-px bg-zinc-800 shrink-0 my-1" />
672673
{buckets.map((b) => (
673-
<div
674+
<a
674675
key={b.label}
675-
className="bg-zinc-900 border border-zinc-800 rounded-md px-3 py-2 min-w-0 flex-1"
676+
href={`/?exhaustion=${b.min}-${b.max}`}
677+
className="bg-zinc-900 border border-zinc-800 rounded-md px-3 py-2 min-w-0 flex-1 hover:border-zinc-600 transition-colors cursor-pointer"
678+
title={`View users who exhausted plan on ${b.label}`}
676679
>
677680
<div className="flex items-center gap-1.5">
678681
<span
@@ -685,7 +688,7 @@ function PlanExhaustionSection({ data }: { data: PlanExhaustionData }) {
685688
{b.count}
686689
</div>
687690
<div className="text-[10px] text-zinc-400">users</div>
688-
</div>
691+
</a>
689692
))}
690693
</div>
691694

@@ -1039,12 +1042,31 @@ function ChartCard({ title, children }: { title: string; children: React.ReactNo
10391042
);
10401043
}
10411044

1042-
function MiniKpi({ label, value, sub }: { label: string; value: string; sub?: string }) {
1043-
return (
1044-
<div className="bg-zinc-900 border border-zinc-800 rounded-md px-3 py-2 min-w-0 flex-1">
1045+
function MiniKpi({
1046+
label,
1047+
value,
1048+
sub,
1049+
href,
1050+
}: {
1051+
label: string;
1052+
value: string;
1053+
sub?: string;
1054+
href?: string;
1055+
}) {
1056+
const content = (
1057+
<>
10451058
<div className="text-[10px] text-zinc-400 truncate">{label}</div>
10461059
<div className="text-lg font-bold tracking-tight leading-tight text-zinc-100">{value}</div>
10471060
{sub && <div className="text-[10px] text-zinc-400 truncate">{sub}</div>}
1048-
</div>
1061+
</>
10491062
);
1063+
const className = `bg-zinc-900 border border-zinc-800 rounded-md px-3 py-2 min-w-0 flex-1${href ? " hover:border-zinc-600 transition-colors cursor-pointer" : ""}`;
1064+
if (href) {
1065+
return (
1066+
<a href={href} className={className}>
1067+
{content}
1068+
</a>
1069+
);
1070+
}
1071+
return <div className={className}>{content}</div>;
10501072
}

src/app/users/[email]/user-detail-client.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@ interface UserStats {
8585
aiPercent: number;
8686
} | null;
8787
} | null;
88+
planExhaustion: {
89+
daysToExhaust: number;
90+
usageBasedReqs: number;
91+
cycleDay: number;
92+
} | null;
8893
}
8994

9095
interface UserDetailClientProps {
@@ -265,6 +270,16 @@ export function UserDetailClient({ email, stats }: UserDetailClientProps) {
265270
}
266271
href={stats.group ? `/?group=${stats.group.id}` : "/"}
267272
/>
273+
<KpiCard
274+
label="Plan Exhausted"
275+
value={stats.planExhaustion ? `Day ${stats.planExhaustion.daysToExhaust}` : "Not yet"}
276+
sub={
277+
stats.planExhaustion
278+
? `${stats.planExhaustion.usageBasedReqs.toLocaleString()} extra reqs (day ${stats.planExhaustion.cycleDay} of cycle)`
279+
: "Still within included plan"
280+
}
281+
alert={stats.planExhaustion != null && stats.planExhaustion.daysToExhaust <= 7}
282+
/>
268283
</div>
269284

270285
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">

src/lib/db.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1611,6 +1611,37 @@ export function getUserStats(email: string, days: number = 7) {
16111611
)
16121612
.get(email) as { id: string; name: string } | undefined;
16131613

1614+
const cycleRow = db.prepare("SELECT MAX(cycle_start) as cs FROM spending").get() as {
1615+
cs: string | null;
1616+
};
1617+
const cycleStart = cycleRow?.cs;
1618+
let planExhaustion: { daysToExhaust: number; usageBasedReqs: number; cycleDay: number } | null =
1619+
null;
1620+
if (cycleStart) {
1621+
const exhaustRow = db
1622+
.prepare(
1623+
`SELECT CAST(julianday(MIN(du.date)) - julianday(?) + 1 AS INT) as days_to_exhaust,
1624+
SUM(du.usage_based_reqs) as usage_based_reqs
1625+
FROM daily_usage du
1626+
WHERE du.email = ? AND du.date >= ? AND du.usage_based_reqs > 0`,
1627+
)
1628+
.get(cycleStart, email, cycleStart) as
1629+
| {
1630+
days_to_exhaust: number | null;
1631+
usage_based_reqs: number | null;
1632+
}
1633+
| undefined;
1634+
if (exhaustRow?.days_to_exhaust != null) {
1635+
const cycleDayNum =
1636+
Math.floor((Date.now() - new Date(cycleStart).getTime()) / (24 * 60 * 60 * 1000)) + 1;
1637+
planExhaustion = {
1638+
daysToExhaust: exhaustRow.days_to_exhaust,
1639+
usageBasedReqs: exhaustRow.usage_based_reqs ?? 0,
1640+
cycleDay: cycleDayNum,
1641+
};
1642+
}
1643+
}
1644+
16141645
return {
16151646
member,
16161647
spending,
@@ -1632,6 +1663,7 @@ export function getUserStats(email: string, days: number = 7) {
16321663
contextMetrics,
16331664
badges,
16341665
aiAdoption,
1666+
planExhaustion,
16351667
};
16361668
}
16371669

0 commit comments

Comments
 (0)