From e4a69574ad3778bb53e0de0e6a9f400869f3a4b8 Mon Sep 17 00:00:00 2001 From: Matt Rubens Date: Sun, 1 Jun 2025 07:45:39 -0400 Subject: [PATCH] Show usage charts (#63) --- package.json | 1 + pnpm-lock.yaml | 237 +++++++++++++++++ src/actions/analytics/events.ts | 66 +++++ src/components/usage/UsageCard.tsx | 135 ++++++---- src/components/usage/UsageChart.tsx | 386 ++++++++++++++++++++++++++++ src/components/usage/index.ts | 1 + src/i18n/locales/en.json | 5 +- 7 files changed, 787 insertions(+), 44 deletions(-) create mode 100644 src/components/usage/UsageChart.tsx diff --git a/package.json b/package.json index b18afeed45..12c8790fd9 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2cba7371ae..4d616229c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/actions/analytics/events.ts b/src/actions/analytics/events.ts index 08d3987117..cf6ae5cb99 100644 --- a/src/actions/analytics/events.ts +++ b/src/actions/analytics/events.ts @@ -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 & { + user: User; +}; + +export const getDailyUsageByUser = async ({ + orgId, + timePeriod = 90, +}: { + orgId?: string | null; + timePeriod?: TimePeriod; +}): Promise => { + 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); +}; diff --git a/src/components/usage/UsageCard.tsx b/src/components/usage/UsageCard.tsx index 8ad568ce98..44253c980d 100644 --- a/src/components/usage/UsageCard.tsx +++ b/src/components/usage/UsageCard.tsx @@ -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(7); + const [selectedMetric, setSelectedMetric] = useState('tasks'); const path = usePathname(); @@ -65,48 +67,95 @@ export const UsageCard = () => {
-
- {timePeriods.map((period) => ( - - ))} -
-
- - - - - +
+ {/* Time period toggles */} +
+ {timePeriods.map((period) => ( + + ))} +
+ + {/* Chart metric toggles - only show on usage page */} + {path === '/usage' && ( +
+ {(['tasks', 'tokens', 'cost'] as MetricType[]).map( + (metric) => ( + + ), + )} +
)} - isPending={isPending} - /> +
+ + {/* Compact inline metrics */} +
+
+ Developers: + + {isPending + ? '...' + : usage[TelemetryEventName.TASK_CREATED]?.users || 0} + +
+
+ Tasks: + + {isPending + ? '...' + : `${usage[TelemetryEventName.TASK_CREATED]?.events || 0}/${usage[TelemetryEventName.TASK_COMPLETED]?.events || 0}`} + +
+
+ Tokens: + + {isPending + ? '...' + : formatNumber( + usage[TelemetryEventName.LLM_COMPLETION]?.tokens || 0, + )} + +
+
+ Cost: + + {isPending + ? '...' + : formatCurrency( + usage[TelemetryEventName.LLM_COMPLETION]?.cost || 0, + )} + +
+
+ + {/* Chart displayed below on usage page */} + {path === '/usage' && ( + + )}
diff --git a/src/components/usage/UsageChart.tsx b/src/components/usage/UsageChart.tsx new file mode 100644 index 0000000000..b386868d2d --- /dev/null +++ b/src/components/usage/UsageChart.tsx @@ -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 ( + + + {formattedDate} + + + ); +}; + +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 ( + + + {formatValue && typeof value === 'number' + ? formatValue(value) + : payload.value} + + + ); +}; + +// 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 ( +
+

+ {formattedDate} +

+
+ {payload.map((entry, index) => ( +
+
+
+ + {entry.dataKey} + +
+ + {typeof entry.value === 'number' && formatValue + ? formatValue(entry.value) + : entry.value} + +
+ ))} +
+
+ ); + } + 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, + ); + + // 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(); + 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 ( +
+
+ {metricTypes.map((metric) => ( + + ))} +
+
+
+
+
+ {Array.from({ length: 7 }).map((_, i) => ( + + ))} +
+
+ + + +
+
+
+
+
+ ); + } + + if (!chartData.length) { + return ( +
+
+
+
+ + + +
+
+

+ No data available +

+

+ No usage data found for the selected period +

+
+
+
+
+ ); + } + + return ( +
+
+ + + + } + height={25} + /> + } + width={55} + /> + + } + cursor={{ + fill: 'hsl(var(--muted))', + opacity: 0.1, + }} + /> + {uniqueUsers.map((user, index) => ( + + ))} + + +
+
+ ); +}; diff --git a/src/components/usage/index.ts b/src/components/usage/index.ts index f273b86c61..bb2bca7841 100644 --- a/src/components/usage/index.ts +++ b/src/components/usage/index.ts @@ -1,2 +1,3 @@ export { UsageCard } from './UsageCard'; export { TaskCard } from './TaskCard'; +export { UsageChart } from './UsageChart'; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 2a3385d7a3..34506f966e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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" } }