Show usage charts (#63)

This commit is contained in:
Matt Rubens 2025-06-01 07:45:39 -04:00 committed by GitHub
parent db2d8d3d50
commit e4a69574ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 787 additions and 44 deletions

View file

@ -72,6 +72,7 @@
"react-hook-form": "^7.56.4",
"react-markdown": "^10.1.0",
"react-use": "^17.6.0",
"recharts": "^2.15.3",
"sonner": "^2.0.3",
"stripe": "^18.2.0",
"tailwind-merge": "^3.3.0",

237
pnpm-lock.yaml generated
View file

@ -143,6 +143,9 @@ importers:
react-use:
specifier: ^17.6.0
version: 17.6.0(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
recharts:
specifier: ^2.15.3
version: 2.15.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
sonner:
specifier: ^2.0.3
version: 2.0.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
@ -3570,6 +3573,33 @@ packages:
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
'@types/d3-array@3.2.1':
resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==}
'@types/d3-color@3.1.3':
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
'@types/d3-ease@3.0.2':
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
'@types/d3-interpolate@3.0.4':
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
'@types/d3-path@3.1.1':
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
'@types/d3-scale@4.0.9':
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
'@types/d3-shape@3.1.7':
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
'@types/d3-time@3.0.4':
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
'@types/d3-timer@3.0.2':
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
'@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
@ -4612,6 +4642,50 @@ packages:
resolution: {integrity: sha512-YGZxdTTL9lmLkCUTpg4j0zQ7IhRB5ZmqNBbGCl3Tg6MP/d5/6sY7L5mmTjzbc6JKgVZYiqTQTNhPFsbXNGlRaA==}
engines: {node: '>=0.8'}
d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
d3-format@3.1.0:
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
engines: {node: '>=12'}
d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
data-uri-to-buffer@6.0.2:
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
engines: {node: '>= 14'}
@ -4658,6 +4732,9 @@ packages:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
decimal.js@10.5.0:
resolution: {integrity: sha512-8vDa8Qxvr/+d94hSh5P3IJwI5t8/c0KsMp+g8bNw9cY2icONa5aPfvKeieW1WlG0WQYwwhJ7mjui2xtiePQSXw==}
@ -4762,6 +4839,9 @@ packages:
dom-converter@0.2.0:
resolution: {integrity: sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==}
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dom-serializer@0.2.2:
resolution: {integrity: sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==}
@ -5199,6 +5279,10 @@ packages:
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-equals@5.2.2:
resolution: {integrity: sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==}
engines: {node: '>=6.0.0'}
fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
@ -5716,6 +5800,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
intl-messageformat@10.7.16:
resolution: {integrity: sha512-UmdmHUmp5CIKKjSoE10la5yfU+AYJAaiYLsodbjL4lji83JNvgOQUjGaGhGrpFCb0Uh7sl7qfP1IyILa8Z40ug==}
@ -7413,6 +7501,12 @@ packages:
'@types/react':
optional: true
react-smooth@4.0.4:
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-style-singleton@2.2.3:
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@ -7423,6 +7517,12 @@ packages:
'@types/react':
optional: true
react-transition-group@4.4.5:
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
react: '>=16.6.0'
react-dom: '>=16.6.0'
react-universal-interface@0.6.2:
resolution: {integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==}
peerDependencies:
@ -7474,6 +7574,16 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
recharts@2.15.3:
resolution: {integrity: sha512-EdOPzTwcFSuqtvkDoaM5ws/Km1+WTAO2eizL7rqiG0V2UVhTnz0m7J2i0CjVPUCdEkZImaWvXLbZDS2H5t6GFQ==}
engines: {node: '>=14'}
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
@ -8602,6 +8712,9 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
vite-node@3.1.4:
resolution: {integrity: sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@ -12491,6 +12604,30 @@ snapshots:
dependencies:
'@types/node': 22.15.27
'@types/d3-array@3.2.1': {}
'@types/d3-color@3.1.3': {}
'@types/d3-ease@3.0.2': {}
'@types/d3-interpolate@3.0.4':
dependencies:
'@types/d3-color': 3.1.3
'@types/d3-path@3.1.1': {}
'@types/d3-scale@4.0.9':
dependencies:
'@types/d3-time': 3.0.4
'@types/d3-shape@3.1.7':
dependencies:
'@types/d3-path': 3.1.1
'@types/d3-time@3.0.4': {}
'@types/d3-timer@3.0.2': {}
'@types/debug@4.1.12':
dependencies:
'@types/ms': 2.1.0
@ -13736,6 +13873,44 @@ snapshots:
find-pkg: 0.1.2
fs-exists-sync: 0.1.0
d3-array@3.2.4:
dependencies:
internmap: 2.0.3
d3-color@3.1.0: {}
d3-ease@3.0.1: {}
d3-format@3.1.0: {}
d3-interpolate@3.0.1:
dependencies:
d3-color: 3.1.0
d3-path@3.1.0: {}
d3-scale@4.0.2:
dependencies:
d3-array: 3.2.4
d3-format: 3.1.0
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
d3-shape@3.2.0:
dependencies:
d3-path: 3.1.0
d3-time-format@4.1.0:
dependencies:
d3-time: 3.1.0
d3-time@3.1.0:
dependencies:
d3-array: 3.2.4
d3-timer@3.0.1: {}
data-uri-to-buffer@6.0.2: {}
data-urls@5.0.0:
@ -13778,6 +13953,8 @@ snapshots:
decamelize@1.2.0: {}
decimal.js-light@2.5.1: {}
decimal.js@10.5.0: {}
decode-named-character-reference@1.1.0:
@ -13868,6 +14045,11 @@ snapshots:
dependencies:
utila: 0.4.0
dom-helpers@5.2.1:
dependencies:
'@babel/runtime': 7.27.3
csstype: 3.1.3
dom-serializer@0.2.2:
dependencies:
domelementtype: 2.3.0
@ -14384,6 +14566,8 @@ snapshots:
fast-deep-equal@3.1.3: {}
fast-equals@5.2.2: {}
fast-glob@3.3.1:
dependencies:
'@nodelib/fs.stat': 2.0.5
@ -14963,6 +15147,8 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
internmap@2.0.3: {}
intl-messageformat@10.7.16:
dependencies:
'@formatjs/ecma402-abstract': 2.3.4
@ -17083,6 +17269,14 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.6
react-smooth@4.0.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
fast-equals: 5.2.2
prop-types: 15.8.1
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-transition-group: 4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
react-style-singleton@2.2.3(@types/react@19.1.6)(react@19.1.0):
dependencies:
get-nonce: 1.0.1
@ -17091,6 +17285,15 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.6
react-transition-group@4.4.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/runtime': 7.27.3
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-universal-interface@0.6.2(react@19.1.0)(tslib@2.8.1):
dependencies:
react: 19.1.0
@ -17174,6 +17377,23 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
recharts-scale@0.4.5:
dependencies:
decimal.js-light: 2.5.1
recharts@2.15.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
dependencies:
clsx: 2.1.1
eventemitter3: 4.0.7
lodash: 4.17.21
react: 19.1.0
react-dom: 19.1.0(react@19.1.0)
react-is: 18.3.1
react-smooth: 4.0.4(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
recharts-scale: 0.4.5
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
redent@3.0.0:
dependencies:
indent-string: 4.0.0
@ -18433,6 +18653,23 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.2
victory-vendor@36.9.2:
dependencies:
'@types/d3-array': 3.2.1
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.7
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
vite-node@3.1.4(@types/node@22.15.27)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.40.0)(tsx@4.19.4)(yaml@2.8.0):
dependencies:
cac: 6.7.14

View file

@ -302,3 +302,69 @@ export const getTasks = async ({
.map((usage) => ({ ...usage, user: users[usage.userId] }))
.filter((usage): usage is TaskWithUser => !!usage.user);
};
/**
* getDailyUsageByUser
*/
const dailyUsageByUserSchema = z.object({
date: z.string(),
userId: z.string(),
tasks: z.coerce.number(),
tokens: z.coerce.number(),
cost: z.coerce.number(),
});
export type DailyUsageByUser = z.infer<typeof dailyUsageByUserSchema> & {
user: User;
};
export const getDailyUsageByUser = async ({
orgId,
timePeriod = 90,
}: {
orgId?: string | null;
timePeriod?: TimePeriod;
}): Promise<DailyUsageByUser[]> => {
if (!orgId) {
return [];
}
const results = await analytics.query({
query: `
SELECT
toString(toDate(fromUnixTimestamp(timestamp))) as date,
userId,
SUM(CASE WHEN type = '${TelemetryEventName.TASK_CREATED}' THEN 1 ELSE 0 END) AS tasks,
SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(inputTokens, 0) + COALESCE(outputTokens, 0) ELSE 0 END) AS tokens,
SUM(CASE WHEN type = '${TelemetryEventName.LLM_COMPLETION}' THEN COALESCE(cost, 0) ELSE 0 END) AS cost
FROM events
WHERE
orgId = {orgId: String}
AND timestamp >= toUnixTimestamp(now() - INTERVAL {timePeriod: Int32} DAY)
AND type IN ({types: Array(String)})
GROUP BY 1, 2
ORDER BY date DESC, userId
`,
format: 'JSONEachRow',
query_params: {
orgId,
timePeriod,
types: [
TelemetryEventName.TASK_CREATED,
TelemetryEventName.TASK_COMPLETED,
TelemetryEventName.LLM_COMPLETION,
],
},
});
const dailyUsages = z
.array(dailyUsageByUserSchema)
.parse(await results.json());
const users = await getUsersById(dailyUsages.map(({ userId }) => userId));
return dailyUsages
.map((usage) => ({ ...usage, user: users[usage.userId] }))
.filter((usage): usage is DailyUsageByUser => !!usage.user);
};

View file

@ -12,10 +12,9 @@ import { TelemetryEventName } from '@roo-code/types';
import { type TimePeriod, timePeriods } from '@/types';
import { getUsage } from '@/actions/analytics';
import { formatCurrency } from '@/lib/formatters';
import { formatCurrency, formatNumber } from '@/lib/formatters';
import {
Button,
Card,
CardHeader,
CardTitle,
@ -24,12 +23,15 @@ import {
} from '@/components/ui';
import { Button as EnhancedButton } from '@/components/ui/ecosystem';
import { Metric } from './Metric';
import { UsageChart } from './UsageChart';
type MetricType = 'tasks' | 'tokens' | 'cost';
export const UsageCard = () => {
const t = useTranslations('DashboardIndex');
const { orgId } = useAuth();
const [timePeriod, setTimePeriod] = useState<TimePeriod>(7);
const [selectedMetric, setSelectedMetric] = useState<MetricType>('tasks');
const path = usePathname();
@ -65,48 +67,95 @@ export const UsageCard = () => {
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3">
<div className="flex flex-row flex-wrap gap-1.5">
{timePeriods.map((period) => (
<Button
key={period}
variant={period === timePeriod ? 'default' : 'secondary'}
size="sm"
onClick={() => setTimePeriod(period)}
className="text-xs px-2.5 h-8"
>
{t(`analytics_period_${period}_days`)}
</Button>
))}
</div>
<div className="grid grid-cols-2 gap-3 lg:grid-cols-5">
<Metric
label={t('analytics_active_developers')}
value={usage[TelemetryEventName.TASK_CREATED]?.users}
isPending={isPending}
/>
<Metric
label={t('analytics_tasks_started')}
value={usage[TelemetryEventName.TASK_CREATED]?.events}
isPending={isPending}
/>
<Metric
label={t('analytics_tasks_completed')}
value={usage[TelemetryEventName.TASK_COMPLETED]?.events}
isPending={isPending}
/>
<Metric
label={t('analytics_tokens_consumed')}
value={usage[TelemetryEventName.LLM_COMPLETION]?.tokens}
isPending={isPending}
/>
<Metric
label={t('analytics_llm_costs')}
value={formatCurrency(
usage[TelemetryEventName.LLM_COMPLETION]?.cost,
<div className="flex flex-row flex-wrap gap-4 justify-between items-center">
<div className="flex flex-row gap-3 items-center">
{/* Time period toggles */}
<div className="flex flex-row gap-1">
{timePeriods.map((period) => (
<button
key={period}
onClick={() => setTimePeriod(period)}
className={`px-2 py-1 text-xs font-medium rounded transition-colors ${
period === timePeriod
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
>
{period}d
</button>
))}
</div>
{/* Chart metric toggles - only show on usage page */}
{path === '/usage' && (
<div className="flex flex-row gap-1">
{(['tasks', 'tokens', 'cost'] as MetricType[]).map(
(metric) => (
<button
key={metric}
onClick={() => setSelectedMetric(metric)}
className={`px-2 py-1 text-xs font-medium rounded transition-colors capitalize ${
metric === selectedMetric
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
}`}
>
{metric}
</button>
),
)}
</div>
)}
isPending={isPending}
/>
</div>
{/* Compact inline metrics */}
<div className="flex flex-wrap gap-4 text-xs">
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Developers:</span>
<span className="font-medium">
{isPending
? '...'
: usage[TelemetryEventName.TASK_CREATED]?.users || 0}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Tasks:</span>
<span className="font-medium">
{isPending
? '...'
: `${usage[TelemetryEventName.TASK_CREATED]?.events || 0}/${usage[TelemetryEventName.TASK_COMPLETED]?.events || 0}`}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Tokens:</span>
<span className="font-medium">
{isPending
? '...'
: formatNumber(
usage[TelemetryEventName.LLM_COMPLETION]?.tokens || 0,
)}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-muted-foreground">Cost:</span>
<span className="font-medium">
{isPending
? '...'
: formatCurrency(
usage[TelemetryEventName.LLM_COMPLETION]?.cost || 0,
)}
</span>
</div>
</div>
</div>
{/* Chart displayed below on usage page */}
{path === '/usage' && (
<UsageChart
timePeriod={timePeriod}
selectedMetric={selectedMetric}
/>
)}
</div>
</CardContent>
</Card>

View file

@ -0,0 +1,386 @@
'use client';
import React, { useMemo } from 'react';
import { useAuth } from '@clerk/nextjs';
import { useQuery } from '@tanstack/react-query';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
import type { TimePeriod } from '@/types';
import { getDailyUsageByUser } from '@/actions/analytics';
import { formatNumber } from '@/lib/formatters';
import { Skeleton } from '@/components/ui';
type MetricType = 'tasks' | 'tokens' | 'cost';
const metricTypes: MetricType[] = ['tasks', 'tokens', 'cost'];
interface TickProps {
x?: number;
y?: number;
payload?: {
value: string;
};
formatValue?: (value: number) => string;
}
interface TooltipEntry {
dataKey: string;
value: number;
color: string;
}
interface TooltipProps {
active?: boolean;
payload?: TooltipEntry[];
label?: string;
selectedMetric?: MetricType;
formatValue?: (value: number) => string;
}
interface ChartDataPoint {
date: string;
total: number;
[userKey: string]: string | number;
}
// Elegant color palette inspired by modern design systems
const generateUserColor = (index: number): string => {
const colors = [
'#3B82F6', // Blue
'#10B981', // Emerald
'#8B5CF6', // Violet
'#F59E0B', // Amber
'#EF4444', // Red
'#06B6D4', // Cyan
'#84CC16', // Lime
'#EC4899', // Pink
'#6366F1', // Indigo
'#14B8A6', // Teal
];
return colors[index % colors.length]!;
};
interface UsageChartProps {
timePeriod: TimePeriod;
selectedMetric?: MetricType;
}
// Custom tick components for theme-aware labels
const CustomXAxisTick = (props: TickProps) => {
const { x, y, payload } = props;
if (!payload?.value) return null;
// Parse date string as local date to avoid timezone issues
const [year, month, day] = payload.value.split('-').map(Number);
if (!year || !month || !day) return null;
const date = new Date(year, month - 1, day); // month is 0-indexed
const formattedDate = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
});
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="middle"
className="fill-muted-foreground text-xs font-medium"
>
{formattedDate}
</text>
</g>
);
};
const CustomYAxisTick = (props: TickProps) => {
const { x, y, payload, formatValue } = props;
if (!payload?.value) return null;
const value =
typeof payload.value === 'string'
? parseFloat(payload.value)
: payload.value;
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={4}
textAnchor="end"
className="fill-muted-foreground text-xs font-medium"
>
{formatValue && typeof value === 'number'
? formatValue(value)
: payload.value}
</text>
</g>
);
};
// Custom tooltip component
const CustomTooltip = ({
active,
payload,
label,
formatValue,
}: TooltipProps) => {
if (active && payload && payload.length && label) {
// Parse date string as local date to avoid timezone issues
const [year, month, day] = label.split('-').map(Number);
if (!year || !month || !day) return null;
const date = new Date(year, month - 1, day); // month is 0-indexed
const formattedDate = date.toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
});
return (
<div className="bg-popover border border-border rounded-lg shadow-lg p-3 min-w-[200px]">
<p className="text-sm font-medium text-foreground mb-2">
{formattedDate}
</p>
<div className="space-y-1">
{payload.map((entry, index) => (
<div
key={index}
className="flex items-center justify-between gap-3"
>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: entry.color }}
/>
<span className="text-xs text-muted-foreground">
{entry.dataKey}
</span>
</div>
<span className="text-xs font-medium text-foreground">
{typeof entry.value === 'number' && formatValue
? formatValue(entry.value)
: entry.value}
</span>
</div>
))}
</div>
</div>
);
}
return null;
};
export const UsageChart = ({
timePeriod,
selectedMetric = 'tasks',
}: UsageChartProps) => {
const { orgId } = useAuth();
const { data: dailyUsage = [], isPending } = useQuery({
queryKey: ['getDailyUsageByUser', orgId, timePeriod],
queryFn: () => getDailyUsageByUser({ orgId, timePeriod }),
enabled: !!orgId,
});
const chartData = useMemo(() => {
if (!dailyUsage.length) return [];
// Group data by date
const dateGroups = dailyUsage.reduce(
(acc, item) => {
const date = item.date;
if (!acc[date]) {
acc[date] = { date, total: 0 };
}
const value = item[selectedMetric];
acc[date][item.user.name || item.user.email || 'Unknown'] = value;
acc[date].total += value;
return acc;
},
{} as Record<string, ChartDataPoint>,
);
// Convert to array and sort by date
const result = Object.values(dateGroups).sort((a, b) => {
// Parse dates as local dates to avoid timezone issues
const [yearA, monthA, dayA] = a.date.split('-').map(Number);
const [yearB, monthB, dayB] = b.date.split('-').map(Number);
if (!yearA || !monthA || !dayA || !yearB || !monthB || !dayB) {
return 0;
}
const dateA = new Date(yearA, monthA - 1, dayA);
const dateB = new Date(yearB, monthB - 1, dayB);
return dateA.getTime() - dateB.getTime();
});
return result;
}, [dailyUsage, selectedMetric]);
const uniqueUsers = useMemo(() => {
const users = new Set<string>();
dailyUsage.forEach((item) => {
users.add(item.user.name || item.user.email || 'Unknown');
});
return Array.from(users).sort();
}, [dailyUsage]);
const formatValue = (value: number) => {
switch (selectedMetric) {
case 'cost':
return value < 1 ? `$${value.toFixed(2)}` : `$${value.toFixed(0)}`;
case 'tokens':
return value >= 1000000
? `${(value / 1000000).toFixed(1)}M`
: formatNumber(value);
default:
return value.toString();
}
};
if (isPending) {
return (
<div className="space-y-6">
<div className="flex flex-wrap gap-2 justify-center sm:justify-start">
{metricTypes.map((metric) => (
<Skeleton key={metric} className="h-9 w-20 rounded-md" />
))}
</div>
<div className="h-72 w-full rounded-lg border bg-card p-3">
<div className="h-full w-full flex items-center justify-center">
<div className="space-y-6 w-full">
<div className="flex justify-between items-end h-48 px-4">
{Array.from({ length: 7 }).map((_, i) => (
<Skeleton
key={i}
className="w-12 rounded-t-md"
style={{ height: `${Math.random() * 60 + 30}%` }}
/>
))}
</div>
<div className="flex justify-center gap-4 pt-4">
<Skeleton className="h-3 w-20" />
<Skeleton className="h-3 w-24" />
<Skeleton className="h-3 w-16" />
</div>
</div>
</div>
</div>
</div>
);
}
if (!chartData.length) {
return (
<div className="space-y-6">
<div className="h-72 w-full rounded-lg border bg-card p-3 flex items-center justify-center">
<div className="text-center space-y-3">
<div className="w-12 h-12 mx-auto rounded-full bg-muted/20 flex items-center justify-center">
<svg
className="w-6 h-6 text-muted-foreground/50"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<div>
<p className="text-sm font-medium text-foreground">
No data available
</p>
<p className="text-xs text-muted-foreground">
No usage data found for the selected period
</p>
</div>
</div>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="h-72 w-full rounded-lg border bg-card p-3">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={chartData}
margin={{
top: 5,
right: 5,
left: 0,
bottom: 5,
}}
barCategoryGap="10%"
>
<CartesianGrid
strokeDasharray="3 3"
stroke="hsl(var(--border))"
opacity={0.3}
vertical={false}
/>
<XAxis
dataKey="date"
axisLine={false}
tickLine={false}
tick={<CustomXAxisTick />}
height={25}
/>
<YAxis
tickFormatter={formatValue}
axisLine={false}
tickLine={false}
tick={<CustomYAxisTick formatValue={formatValue} />}
width={55}
/>
<Tooltip
content={
<CustomTooltip
selectedMetric={selectedMetric}
formatValue={formatValue}
/>
}
cursor={{
fill: 'hsl(var(--muted))',
opacity: 0.1,
}}
/>
{uniqueUsers.map((user, index) => (
<Bar
key={user}
dataKey={user}
stackId="usage"
fill={generateUserColor(index)}
radius={
index === uniqueUsers.length - 1 ? [4, 4, 0, 0] : [0, 0, 0, 0]
}
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
</div>
);
};

View file

@ -1,2 +1,3 @@
export { UsageCard } from './UsageCard';
export { TaskCard } from './TaskCard';
export { UsageChart } from './UsageChart';

View file

@ -36,6 +36,9 @@
"view_mode_title": "View By",
"view_mode_developers": "Developers",
"view_mode_models": "Models",
"view_mode_tasks": "Tasks"
"view_mode_tasks": "Tasks",
"chart_metric_tasks": "Tasks",
"chart_metric_tokens": "Tokens",
"chart_metric_cost": "Cost"
}
}