Skip to content

Commit 5670beb

Browse files
committed
Added scatter plot for user behaviour
1 parent da69fb0 commit 5670beb

2 files changed

Lines changed: 281 additions & 1 deletion

File tree

src/App.tsx

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { UserSquare, ChevronRight, ChevronLeft, Shield } from "lucide-react";
44
import { toast, Toaster } from "sonner";
55
import {
66
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
7-
BarChart, Bar, Cell
7+
BarChart, Bar, Cell, ScatterChart, Scatter, ZAxis
88
} from "recharts";
99
import { Card } from "@/components/ui/card";
1010
import { Button } from "@/components/ui/button";
@@ -26,6 +26,7 @@ import {
2626
ProjectedUserData,
2727
MonthOption,
2828
UserAnalysisData,
29+
UserBehaviorDataPoint,
2930
aggregateDataByDay,
3031
parseCSV,
3132
getModelUsageSummary,
@@ -45,6 +46,7 @@ import {
4546
getAvailableMonths,
4647
filterDataByMonth,
4748
getUserAnalysisData,
49+
getUserBehaviorData,
4850
EXCESS_REQUEST_COST
4951
} from "@/lib/utils";
5052
import { MonthSelector } from "@/components/MonthSelector";
@@ -58,6 +60,28 @@ const MODEL_COLORS = [
5860
"#1ABC9C", // Turquoise
5961
];
6062
const OTHER_COLOR = "#94A3B8"; // Slate gray for Other
63+
const BEHAVIOR_COLORS: Record<string, string> = {
64+
'Steady Users': '#16A34A',
65+
'Low Engagement Users': '#64748B',
66+
'Burst Users': '#DC2626',
67+
'Model Explorers': '#0EA5E9',
68+
'Model Loyalists': '#D97706',
69+
'Mixed Behavior': '#7C3AED',
70+
};
71+
72+
type BehaviorScatterPoint = UserBehaviorDataPoint & {
73+
scatterX: number;
74+
scatterY: number;
75+
};
76+
77+
function getUserJitter(user: string): number {
78+
let hash = 0;
79+
for (let i = 0; i < user.length; i++) {
80+
hash = (hash * 31 + user.charCodeAt(i)) >>> 0;
81+
}
82+
// Deterministic range [-0.35, +0.35]
83+
return ((hash % 701) - 350) / 1000;
84+
}
6185

6286
function App() {
6387
const [showPrivacyBanner, setShowPrivacyBanner] = useState(true);
@@ -481,6 +505,25 @@ function App() {
481505

482506
const modelBarData = useMemo(() => barChartData(), [barChartData]);
483507

508+
const behaviorData = useMemo<BehaviorScatterPoint[]>(() => {
509+
if (!displayData || !displayData.length) return [];
510+
return getUserBehaviorData(displayData, selectedPlan).map((item) => {
511+
const jitter = getUserJitter(item.user);
512+
return {
513+
...item,
514+
scatterX: Math.max(0, Math.min(100, item.activeDaysPct + jitter)),
515+
scatterY: Math.max(0, item.utilizationPct + jitter),
516+
};
517+
});
518+
}, [displayData, selectedPlan]);
519+
520+
const behaviorSegmentCounts = useMemo(() => {
521+
return behaviorData.reduce((acc, userPoint) => {
522+
acc[userPoint.behaviorSegment] = (acc[userPoint.behaviorSegment] || 0) + 1;
523+
return acc;
524+
}, {} as Record<string, number>);
525+
}, [behaviorData]);
526+
484527
const modelBarYAxis = useMemo(() => {
485528
if (!modelBarData.length) {
486529
return {
@@ -1738,6 +1781,94 @@ function App() {
17381781
</BarChart>
17391782
</ChartContainer>
17401783
</div>
1784+
1785+
1786+
1787+
{/* Behavior Segmentation Scatter */}
1788+
<div className="flex justify-between items-center mb-2 mt-8">
1789+
<h2 className="text-2xl font-semibold">
1790+
User Behavior Segments
1791+
{selectedSearchUser && (
1792+
<span className="ml-2 text-lg font-medium text-blue-600">
1793+
- {selectedSearchUser}
1794+
</span>
1795+
)}
1796+
</h2>
1797+
<div className="text-sm text-muted-foreground">
1798+
X: Active Days % of Month, Y: Quota Utilization %
1799+
</div>
1800+
</div>
1801+
<Separator className="mb-6" />
1802+
<div className="bg-card p-4 rounded-lg border mb-8">
1803+
<div className="flex flex-wrap gap-2 mb-4">
1804+
{Object.entries(behaviorSegmentCounts).map(([segment, count]) => (
1805+
<div key={segment} className="text-xs px-2.5 py-1 rounded-full border flex items-center gap-1.5">
1806+
<span
1807+
className="w-2 h-2 rounded-full"
1808+
style={{ backgroundColor: BEHAVIOR_COLORS[segment] || '#7C3AED' }}
1809+
/>
1810+
<span className="font-medium">{segment}:</span>
1811+
<span>{count.toLocaleString()}</span>
1812+
</div>
1813+
))}
1814+
</div>
1815+
1816+
<div className="h-[460px] w-full">
1817+
<ResponsiveContainer width="100%" height="100%">
1818+
<ScatterChart margin={{ top: 16, right: 24, left: 8, bottom: 8 }}>
1819+
<CartesianGrid strokeDasharray="3 3" opacity={0.2} />
1820+
<XAxis
1821+
type="number"
1822+
dataKey="scatterX"
1823+
name="Active Days %"
1824+
tick={{ fill: 'var(--foreground)' }}
1825+
tickLine={{ stroke: 'var(--border)' }}
1826+
domain={[0, 100]}
1827+
/>
1828+
<YAxis
1829+
type="number"
1830+
dataKey="scatterY"
1831+
name="Utilization %"
1832+
tick={{ fill: 'var(--foreground)' }}
1833+
tickLine={{ stroke: 'var(--border)' }}
1834+
domain={[0, 'auto']}
1835+
/>
1836+
<ZAxis type="number" dataKey="modelDiversity" range={[80, 420]} />
1837+
<Tooltip
1838+
cursor={{ strokeDasharray: '3 3' }}
1839+
content={({ active, payload }) => {
1840+
if (active && payload && payload.length) {
1841+
const point = payload[0].payload as BehaviorScatterPoint;
1842+
return (
1843+
<div className="border rounded-lg bg-background shadow-lg p-3 text-xs">
1844+
<div className="font-medium mb-2">{point.user}</div>
1845+
<div className="space-y-1">
1846+
<div>Segment: <span className="font-medium">{point.behaviorSegment}</span></div>
1847+
<div>Utilization: <span className="font-medium">{point.utilizationPct.toLocaleString(undefined, { maximumFractionDigits: 1 })}%</span></div>
1848+
<div>Active Days: <span className="font-medium">{point.activeDaysPct.toLocaleString(undefined, { maximumFractionDigits: 1 })}%</span></div>
1849+
<div>Models Used: <span className="font-medium">{point.modelDiversity.toLocaleString()}</span></div>
1850+
<div>Top Model Share: <span className="font-medium">{point.topModelSharePct.toLocaleString(undefined, { maximumFractionDigits: 1 })}%</span></div>
1851+
<div>First Week Usage: <span className="font-medium">{(point.frontloadIndex * 100).toLocaleString(undefined, { maximumFractionDigits: 1 })}%</span></div>
1852+
<div>Total Requests: <span className="font-medium">{point.totalRequests.toLocaleString(undefined, { maximumFractionDigits: 2, minimumFractionDigits: 0 })}</span></div>
1853+
</div>
1854+
</div>
1855+
);
1856+
}
1857+
return null;
1858+
}}
1859+
/>
1860+
<Scatter name="Users" data={behaviorData}>
1861+
{behaviorData.map((point) => (
1862+
<Cell
1863+
key={point.user}
1864+
fill={BEHAVIOR_COLORS[point.behaviorSegment] || '#7C3AED'}
1865+
/>
1866+
))}
1867+
</Scatter>
1868+
</ScatterChart>
1869+
</ResponsiveContainer>
1870+
</div>
1871+
</div>
17411872
</div>
17421873
</div>
17431874
)}

src/lib/utils.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -817,6 +817,155 @@ export interface UserAnalysisData {
817817
lastActivityDate: string;
818818
}
819819

820+
export type BehaviorSegment =
821+
| 'Steady Users'
822+
| 'Low Engagement Users'
823+
| 'Burst Users'
824+
| 'Model Explorers'
825+
| 'Model Loyalists'
826+
| 'Mixed Behavior';
827+
828+
export interface UserBehaviorDataPoint {
829+
user: string;
830+
behaviorSegment: BehaviorSegment;
831+
utilizationPct: number;
832+
activeDaysPct: number;
833+
modelDiversity: number;
834+
topModelSharePct: number;
835+
frontloadIndex: number;
836+
totalRequests: number;
837+
}
838+
839+
function getStandardDeviation(values: number[]): number {
840+
if (values.length <= 1) return 0;
841+
const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
842+
const variance = values.reduce((sum, value) => sum + Math.pow(value - mean, 2), 0) / values.length;
843+
return Math.sqrt(variance);
844+
}
845+
846+
/**
847+
* Build behavior segmentation points for each user in a month.
848+
* The output is optimized for scatter charts (engagement vs utilization).
849+
*/
850+
export function getUserBehaviorData(
851+
data: CopilotUsageData[],
852+
plan: string = COPILOT_PLANS.BUSINESS
853+
): UserBehaviorDataPoint[] {
854+
if (!data.length) return [];
855+
856+
const planLimit = PLAN_MONTHLY_LIMITS[plan] || PLAN_MONTHLY_LIMITS[COPILOT_PLANS.BUSINESS];
857+
const lastTimestamp = Math.max(...data.map(item => item.timestamp.getTime()));
858+
const referenceDate = new Date(lastTimestamp);
859+
const totalDaysInMonth = new Date(
860+
referenceDate.getFullYear(),
861+
referenceDate.getMonth() + 1,
862+
0
863+
).getDate();
864+
865+
const users = new Map<string, {
866+
totalRequests: number;
867+
dailyTotals: Map<string, number>;
868+
modelTotals: Map<string, number>;
869+
firstWeekRequests: number;
870+
}>();
871+
872+
data.forEach(item => {
873+
const date = item.timestamp.toISOString().split('T')[0];
874+
const dayOfMonth = item.timestamp.getDate();
875+
876+
if (!users.has(item.user)) {
877+
users.set(item.user, {
878+
totalRequests: 0,
879+
dailyTotals: new Map<string, number>(),
880+
modelTotals: new Map<string, number>(),
881+
firstWeekRequests: 0,
882+
});
883+
}
884+
885+
const userData = users.get(item.user)!;
886+
userData.totalRequests += item.requestsUsed;
887+
userData.dailyTotals.set(date, (userData.dailyTotals.get(date) || 0) + item.requestsUsed);
888+
userData.modelTotals.set(item.model, (userData.modelTotals.get(item.model) || 0) + item.requestsUsed);
889+
890+
if (dayOfMonth <= 7) {
891+
userData.firstWeekRequests += item.requestsUsed;
892+
}
893+
});
894+
895+
const classifyBehavior = (
896+
utilizationPct: number,
897+
activeDaysPct: number,
898+
frontloadIndex: number,
899+
dailyVariability: number,
900+
modelDiversity: number,
901+
topModelSharePct: number
902+
): BehaviorSegment => {
903+
if (utilizationPct < 15 && activeDaysPct < 20) return 'Low Engagement Users';
904+
905+
if (
906+
utilizationPct >= 70 &&
907+
activeDaysPct >= 45 &&
908+
frontloadIndex <= 0.45 &&
909+
dailyVariability <= 1.35
910+
) {
911+
return 'Steady Users';
912+
}
913+
914+
if (utilizationPct >= 70 && frontloadIndex >= 0.55) {
915+
return 'Burst Users';
916+
}
917+
918+
if (modelDiversity >= 4 && topModelSharePct <= 60) {
919+
return 'Model Explorers';
920+
}
921+
922+
if (modelDiversity <= 2 && topModelSharePct >= 75) {
923+
return 'Model Loyalists';
924+
}
925+
926+
return 'Mixed Behavior';
927+
};
928+
929+
return Array.from(users.entries())
930+
.map(([user, userData]) => {
931+
const activeDays = userData.dailyTotals.size;
932+
const utilizationPct = (userData.totalRequests / planLimit) * 100;
933+
const activeDaysPct = (activeDays / totalDaysInMonth) * 100;
934+
const frontloadIndex = userData.totalRequests > 0 ? userData.firstWeekRequests / userData.totalRequests : 0;
935+
936+
const dailySeries = Array.from(userData.dailyTotals.values());
937+
const dailyAverage = dailySeries.length
938+
? dailySeries.reduce((sum, value) => sum + value, 0) / dailySeries.length
939+
: 0;
940+
const dailyVariability = dailyAverage > 0 ? getStandardDeviation(dailySeries) / dailyAverage : 0;
941+
942+
const modelTotals = Array.from(userData.modelTotals.values());
943+
const topModelSharePct = modelTotals.length
944+
? (Math.max(...modelTotals) / userData.totalRequests) * 100
945+
: 0;
946+
const modelDiversity = userData.modelTotals.size;
947+
948+
return {
949+
user,
950+
behaviorSegment: classifyBehavior(
951+
utilizationPct,
952+
activeDaysPct,
953+
frontloadIndex,
954+
dailyVariability,
955+
modelDiversity,
956+
topModelSharePct
957+
),
958+
utilizationPct,
959+
activeDaysPct,
960+
modelDiversity,
961+
topModelSharePct,
962+
frontloadIndex,
963+
totalRequests: userData.totalRequests,
964+
};
965+
})
966+
.sort((a, b) => b.totalRequests - a.totalRequests);
967+
}
968+
820969
/**
821970
* Get ISO week number for a given date
822971
*/

0 commit comments

Comments
 (0)