diff --git a/package-lock.json b/package-lock.json index b301e0b..fc3eb84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4", + "recharts": "^3.8.1", "shadcn": "^4.2.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", @@ -2024,6 +2025,42 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz", + "integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.8", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.8.tgz", + "integrity": "sha512-/tbkHMW7y10Lx6i1crLjD4/OhNkRG+Fo7byZHtah0547nIeXYcpIXaUh0IAQY6gO5459qpGGYapcEOHtFXkIuA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", @@ -2336,7 +2373,12 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, "node_modules/@supabase/auth-js": { @@ -2891,6 +2933,12 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", @@ -2906,6 +2954,12 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -2915,12 +2969,48 @@ "@types/d3-color": "*" } }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, "node_modules/@types/d3-selection": { "version": "3.0.11", "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", "license": "MIT" }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, "node_modules/@types/d3-transition": { "version": "3.0.9", "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", @@ -3004,6 +3094,12 @@ "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "license": "MIT" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@types/validate-npm-package-name": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", @@ -4628,6 +4724,18 @@ "devOptional": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -4668,6 +4776,15 @@ "node": ">=12" } }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-interpolate": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", @@ -4680,6 +4797,31 @@ "node": ">=12" } }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-selection": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", @@ -4689,6 +4831,42 @@ "node": ">=12" } }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-timer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", @@ -4830,6 +5008,12 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, "node_modules/dedent": { "version": "1.7.2", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", @@ -5261,6 +5445,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.47.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.47.0.tgz", + "integrity": "sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -5727,6 +5921,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", @@ -6569,6 +6769,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -6616,6 +6826,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/ip-address": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -8927,9 +9146,31 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true, "license": "MIT" }, + "node_modules/react-redux": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz", + "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -8946,6 +9187,57 @@ "node": ">= 4" } }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts/node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -10543,6 +10835,28 @@ "node": ">= 0.8" } }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "8.0.14", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", diff --git a/package.json b/package.json index f4f5e35..a0d863b 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "next": "16.2.6", "react": "19.2.4", "react-dom": "19.2.4", + "recharts": "^3.8.1", "shadcn": "^4.2.0", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", diff --git a/src/components/dashboard/response-time-chart.tsx b/src/components/dashboard/response-time-chart.tsx index 7b903da..33242b4 100644 --- a/src/components/dashboard/response-time-chart.tsx +++ b/src/components/dashboard/response-time-chart.tsx @@ -3,19 +3,27 @@ import { Clock } from 'lucide-react' import { DOW_SHORT_MON_FIRST } from '@/lib/dashboard/date-utils' import type { ResponseTimeSummary } from '@/lib/dashboard/types' +import { BarChart } from '@/components/tremor/bar-chart' import { EmptyState } from './empty-state' import { Skeleton } from './skeleton' interface ResponseTimeChartProps { data: ResponseTimeSummary | null loading: boolean - /** Minutes. Horizontal dashed line rendered at this height. */ + /** Minutes. Surfaced as a "target" pill in the header. The + * hand-rolled SVG version drew this as a horizontal dashed + * line on the chart; Tremor BarChart doesn't expose Recharts + * primitives, so we promote it to the header for now. A + * follow-up can introduce an overlay or extend the vendored + * BarChart with a `referenceLines` prop. */ thresholdMinutes?: number } -const VB_W = 760 -const VB_H = 220 -const PADDING = { top: 24, right: 16, bottom: 32, left: 44 } +// Single category, single colour — the data is "average minutes +// per weekday". Tremor expects categories as the second tuple in +// the row object, so we shape the buckets into +// `{ day: 'Mon', 'Avg minutes': 4.2 }` rows below. +const CATEGORY = 'Avg minutes' export function ResponseTimeChart({ data, @@ -24,9 +32,20 @@ export function ResponseTimeChart({ }: ResponseTimeChartProps) { const hasData = data?.buckets.some((b) => b.avgMinutes != null) ?? false + // Map buckets → Tremor rows. Null `avgMinutes` (no samples) + // collapses to 0; the chart will render an empty slot for it. + // We attach `samples` on the row so a future customTooltip can + // surface "no samples" copy without losing the data shape. + const chartData = + data?.buckets.map((b, i) => ({ + day: DOW_SHORT_MON_FIRST[i], + [CATEGORY]: b.avgMinutes ?? 0, + samples: b.samples, + })) ?? [] + return (
-
+

Average First Response Time @@ -36,25 +55,32 @@ export function ResponseTimeChart({ weekday

- {data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && ( -
-
- This week:{' '} - - {fmt(data.thisWeekAvg)} - +
+ {thresholdMinutes > 0 && ( + + target {thresholdMinutes}m + + )} + {data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && ( +
+
+ This week:{' '} + + {fmt(data.thisWeekAvg)} + +
+
+ Last week:{' '} + {fmt(data.lastWeekAvg)} +
-
- Last week:{' '} - {fmt(data.lastWeekAvg)} -
-
- )} + )} +
{loading || !data ? ( - + ) : !hasData ? ( ) : ( - + `${value.toFixed(1)}m`} + showLegend={false} + yAxisWidth={48} + // Compact height so the chart sits well inside the card + // without dominating the row alongside the donut + activity feed. + className="h-[260px]" + /> )}
) } -function Bars({ - data, - thresholdMinutes, -}: { - data: ResponseTimeSummary - thresholdMinutes: number -}) { - const chartW = VB_W - PADDING.left - PADDING.right - const chartH = VB_H - PADDING.top - PADDING.bottom - - const values = data.buckets.map((b) => b.avgMinutes ?? 0) - const rawMax = Math.max(thresholdMinutes * 1.2, ...values) - const maxY = niceCeil(rawMax) - const yFor = (v: number) => - maxY === 0 ? PADDING.top + chartH : PADDING.top + chartH - (v / maxY) * chartH - - const barSlot = chartW / 7 - const barW = Math.min(44, barSlot * 0.55) - - const ticks = [0, maxY / 2, maxY].map((t) => Math.round(t)) - - return ( - - {/* Y grid */} - {ticks.map((t) => { - const y = yFor(t) - return ( - - - - {t}m - - - ) - })} - - {/* Threshold line — rendered after grid so it sits on top, but - we keep it muted so bars remain the visual focus. */} - {thresholdMinutes > 0 && thresholdMinutes <= maxY && ( - - - - target {thresholdMinutes}m - - - )} - - {/* Bars */} - {data.buckets.map((b, i) => { - const v = b.avgMinutes ?? 0 - const x = PADDING.left + barSlot * i + (barSlot - barW) / 2 - const y = yFor(v) - const h = PADDING.top + chartH - y - const muted = b.avgMinutes == null - return ( - - - - {DOW_SHORT_MON_FIRST[i]}:{' '} - {b.avgMinutes == null ? 'no samples' : `${b.avgMinutes.toFixed(1)} min avg`} - {b.samples > 0 ? ` (${b.samples} sample${b.samples === 1 ? '' : 's'})` : ''} - - - - {DOW_SHORT_MON_FIRST[i]} - - - ) - })} - - ) -} - function fmt(mins: number | null): string { if (mins == null) return '—' if (mins < 1) return `${Math.max(1, Math.round(mins * 60))}s` if (mins < 60) return `${mins.toFixed(1)}m` return `${(mins / 60).toFixed(1)}h` } - -function niceCeil(max: number): number { - if (max <= 0) return 10 - const pow = Math.pow(10, Math.floor(Math.log10(max))) - const n = max / pow - let nice: number - if (n <= 1) nice = 1 - else if (n <= 2) nice = 2 - else if (n <= 5) nice = 5 - else nice = 10 - return nice * pow -} diff --git a/src/components/tremor/bar-chart.tsx b/src/components/tremor/bar-chart.tsx new file mode 100644 index 0000000..d4313e4 --- /dev/null +++ b/src/components/tremor/bar-chart.tsx @@ -0,0 +1,889 @@ +// ============================================================ +// Tremor BarChart [v1.0.0] — vendored from tremorlabs/tremor. +// +// Two local adaptations vs the upstream file: +// 1. `cx` (Tremor's `clsx`+`twMerge` helper) is replaced with +// our own `cn` from `@/lib/utils`. Aliased locally so the +// diff against upstream stays scoped to this `import` line. +// 2. The legend slider's arrow icons (`RiArrowLeftSLine` / +// `RiArrowRightSLine` from `@remixicon/react`) are swapped +// for `ChevronLeft` / `ChevronRight` from `lucide-react`, +// which is already in the project's deps. +// +// Everything else — the chart logic, accessibility behaviour, +// tooltip / legend / slider implementation — is unchanged from +// the canonical Tremor Raw source. +// +// License: Apache 2.0 (Tremor). +// Source: https://github.com/tremorlabs/tremor/blob/main/src/components/BarChart/BarChart.tsx +// ============================================================ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +"use client" + +import React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { + Bar, + CartesianGrid, + Label, + BarChart as RechartsBarChart, + Legend as RechartsLegend, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts" +import type { AxisDomain } from "recharts/types/util/types" + +import { cn as cx } from "@/lib/utils" +import { + AvailableChartColors, + type AvailableChartColorsKeys, + constructCategoryColors, + getColorClassName, +} from "./chart-colors" +import { getYAxisDomain } from "./get-y-axis-domain" +import { useOnWindowResize } from "./use-on-window-resize" + +//#region Shape + +function deepEqual(obj1: T, obj2: T): boolean { + if (obj1 === obj2) return true + + if ( + typeof obj1 !== "object" || + typeof obj2 !== "object" || + obj1 === null || + obj2 === null + ) { + return false + } + + const keys1 = Object.keys(obj1) as Array + const keys2 = Object.keys(obj2) as Array + + if (keys1.length !== keys2.length) return false + + for (const key of keys1) { + if (!keys2.includes(key) || !deepEqual(obj1[key], obj2[key])) return false + } + + return true +} + +const renderShape = ( + props: any, + activeBar: any | undefined, + activeLegend: string | undefined, + layout: string, +) => { + const { fillOpacity, name, payload, value } = props + let { x, width, y, height } = props + + if (layout === "horizontal" && height < 0) { + y += height + height = Math.abs(height) + } else if (layout === "vertical" && width < 0) { + x += width + width = Math.abs(width) + } + + return ( + + ) +} + +//#region Legend + +interface LegendItemProps { + name: string + color: AvailableChartColorsKeys + onClick?: (name: string, color: AvailableChartColorsKeys) => void + activeLegend?: string +} + +const LegendItem = ({ + name, + color, + onClick, + activeLegend, +}: LegendItemProps) => { + const hasOnValueChange = !!onClick + return ( +
  • { + e.stopPropagation() + onClick?.(name, color) + }} + > + +

    + {name} +

    +
  • + ) +} + +interface ScrollButtonProps { + icon: React.ElementType + onClick?: () => void + disabled?: boolean +} + +const ScrollButton = ({ icon, onClick, disabled }: ScrollButtonProps) => { + const Icon = icon + const [isPressed, setIsPressed] = React.useState(false) + const intervalRef = React.useRef(null) + + React.useEffect(() => { + if (isPressed) { + intervalRef.current = setInterval(() => { + onClick?.() + }, 300) + } else { + clearInterval(intervalRef.current as NodeJS.Timeout) + } + return () => clearInterval(intervalRef.current as NodeJS.Timeout) + }, [isPressed, onClick]) + + React.useEffect(() => { + if (disabled) { + clearInterval(intervalRef.current as NodeJS.Timeout) + setIsPressed(false) + } + }, [disabled]) + + return ( + + ) +} + +interface LegendProps extends React.OlHTMLAttributes { + categories: string[] + colors?: AvailableChartColorsKeys[] + onClickLegendItem?: (category: string, color: string) => void + activeLegend?: string + enableLegendSlider?: boolean +} + +type HasScrollProps = { + left: boolean + right: boolean +} + +const Legend = React.forwardRef((props, ref) => { + const { + categories, + colors = AvailableChartColors, + className, + onClickLegendItem, + activeLegend, + enableLegendSlider = false, + ...other + } = props + const scrollableRef = React.useRef(null) + const scrollButtonsRef = React.useRef(null) + const [hasScroll, setHasScroll] = React.useState(null) + const [isKeyDowned, setIsKeyDowned] = React.useState(null) + const intervalRef = React.useRef(null) + + const checkScroll = React.useCallback(() => { + const scrollable = scrollableRef?.current + if (!scrollable) return + + const hasLeftScroll = scrollable.scrollLeft > 0 + const hasRightScroll = + scrollable.scrollWidth - scrollable.clientWidth > scrollable.scrollLeft + + setHasScroll({ left: hasLeftScroll, right: hasRightScroll }) + }, [setHasScroll]) + + const scrollToTest = React.useCallback( + (direction: "left" | "right") => { + const element = scrollableRef?.current + const scrollButtons = scrollButtonsRef?.current + const scrollButtonsWith = scrollButtons?.clientWidth ?? 0 + const width = element?.clientWidth ?? 0 + + if (element && enableLegendSlider) { + element.scrollTo({ + left: + direction === "left" + ? element.scrollLeft - width + scrollButtonsWith + : element.scrollLeft + width - scrollButtonsWith, + behavior: "smooth", + }) + setTimeout(() => { + checkScroll() + }, 400) + } + }, + [enableLegendSlider, checkScroll], + ) + + React.useEffect(() => { + const keyDownHandler = (key: string) => { + if (key === "ArrowLeft") { + scrollToTest("left") + } else if (key === "ArrowRight") { + scrollToTest("right") + } + } + if (isKeyDowned) { + keyDownHandler(isKeyDowned) + intervalRef.current = setInterval(() => { + keyDownHandler(isKeyDowned) + }, 300) + } else { + clearInterval(intervalRef.current as NodeJS.Timeout) + } + return () => clearInterval(intervalRef.current as NodeJS.Timeout) + }, [isKeyDowned, scrollToTest]) + + const keyDown = (e: KeyboardEvent) => { + e.stopPropagation() + if (e.key === "ArrowLeft" || e.key === "ArrowRight") { + e.preventDefault() + setIsKeyDowned(e.key) + } + } + const keyUp = (e: KeyboardEvent) => { + e.stopPropagation() + setIsKeyDowned(null) + } + + React.useEffect(() => { + const scrollable = scrollableRef?.current + if (enableLegendSlider) { + checkScroll() + scrollable?.addEventListener("keydown", keyDown) + scrollable?.addEventListener("keyup", keyUp) + } + + return () => { + scrollable?.removeEventListener("keydown", keyDown) + scrollable?.removeEventListener("keyup", keyUp) + } + }, [checkScroll, enableLegendSlider]) + + return ( +
      +
      + {categories.map((category, index) => ( + + ))} +
      + {enableLegendSlider && (hasScroll?.right || hasScroll?.left) ? ( + <> +
      + { + setIsKeyDowned(null) + scrollToTest("left") + }} + disabled={!hasScroll?.left} + /> + { + setIsKeyDowned(null) + scrollToTest("right") + }} + disabled={!hasScroll?.right} + /> +
      + + ) : null} +
    + ) +}) + +Legend.displayName = "Legend" + +const ChartLegend = ( + { payload }: any, + categoryColors: Map, + setLegendHeight: React.Dispatch>, + activeLegend: string | undefined, + onClick?: (category: string, color: string) => void, + enableLegendSlider?: boolean, + legendPosition?: "left" | "center" | "right", + yAxisWidth?: number, +) => { + const legendRef = React.useRef(null) + + useOnWindowResize(() => { + const calculateHeight = (height: number | undefined) => + height ? Number(height) + 15 : 60 + setLegendHeight(calculateHeight(legendRef.current?.clientHeight)) + }) + + const filteredPayload = payload.filter((item: any) => item.type !== "none") + + const paddingLeft = + legendPosition === "left" && yAxisWidth ? yAxisWidth - 8 : 0 + + return ( +
    + entry.value)} + colors={filteredPayload.map((entry: any) => + categoryColors.get(entry.value), + )} + onClickLegendItem={onClick} + activeLegend={activeLegend} + enableLegendSlider={enableLegendSlider} + /> +
    + ) +} + +//#region Tooltip + +type TooltipProps = Pick + +type PayloadItem = { + category: string + value: number + index: string + color: AvailableChartColorsKeys + type?: string + payload: any +} + +interface ChartTooltipProps { + active: boolean | undefined + payload: PayloadItem[] + // Recharts 3.x widened this from `string` to `string | number | undefined`. + // Tremor's source (written against recharts ^2.13.3) still types it as + // `string`; we widen here so the vendored file typechecks against the + // current recharts release without forcing a downgrade. + label: string | number | undefined + valueFormatter: (value: number) => string +} + +const ChartTooltip = ({ + active, + payload, + label, + valueFormatter, +}: ChartTooltipProps) => { + if (active && payload && payload.length) { + return ( +
    +
    +

    + {label} +

    +
    +
    + {payload.map(({ value, category, color }, index) => ( +
    +
    +
    +

    + {valueFormatter(value)} +

    +
    + ))} +
    +
    + ) + } + return null +} + +//#region BarChart + +type BaseEventProps = { + eventType: "category" | "bar" + categoryClicked: string + [key: string]: number | string +} + +type BarChartEventProps = BaseEventProps | null | undefined + +interface BarChartProps extends React.HTMLAttributes { + data: Record[] + index: string + categories: string[] + colors?: AvailableChartColorsKeys[] + valueFormatter?: (value: number) => string + startEndOnly?: boolean + showXAxis?: boolean + showYAxis?: boolean + showGridLines?: boolean + yAxisWidth?: number + intervalType?: "preserveStartEnd" | "equidistantPreserveStart" + showTooltip?: boolean + showLegend?: boolean + autoMinValue?: boolean + minValue?: number + maxValue?: number + allowDecimals?: boolean + onValueChange?: (value: BarChartEventProps) => void + enableLegendSlider?: boolean + tickGap?: number + barCategoryGap?: string | number + xAxisLabel?: string + yAxisLabel?: string + layout?: "vertical" | "horizontal" + type?: "default" | "stacked" | "percent" + legendPosition?: "left" | "center" | "right" + tooltipCallback?: (tooltipCallbackContent: TooltipProps) => void + customTooltip?: React.ComponentType +} + +const BarChart = React.forwardRef( + (props, forwardedRef) => { + const { + data = [], + categories = [], + index, + colors = AvailableChartColors, + valueFormatter = (value: number) => value.toString(), + startEndOnly = false, + showXAxis = true, + showYAxis = true, + showGridLines = true, + yAxisWidth = 56, + intervalType = "equidistantPreserveStart", + showTooltip = true, + showLegend = true, + autoMinValue = false, + minValue, + maxValue, + allowDecimals = true, + className, + onValueChange, + enableLegendSlider = false, + barCategoryGap, + tickGap = 5, + xAxisLabel, + yAxisLabel, + layout = "horizontal", + type = "default", + legendPosition = "right", + tooltipCallback, + customTooltip, + ...other + } = props + const CustomTooltip = customTooltip + const paddingValue = + (!showXAxis && !showYAxis) || (startEndOnly && !showYAxis) ? 0 : 20 + const [legendHeight, setLegendHeight] = React.useState(60) + const [activeLegend, setActiveLegend] = React.useState( + undefined, + ) + const categoryColors = constructCategoryColors(categories, colors) + const [activeBar, setActiveBar] = React.useState(undefined) + const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue) + const hasOnValueChange = !!onValueChange + const stacked = type === "stacked" || type === "percent" + + const prevActiveRef = React.useRef(undefined) + // Widened from `string | undefined` to match the recharts 3.x + // `label` type (see the ChartTooltipProps note below). + const prevLabelRef = React.useRef(undefined) + + function valueToPercent(value: number) { + return `${(value * 100).toFixed(0)}%` + } + + function onBarClick(data: any, _: any, event: React.MouseEvent) { + event.stopPropagation() + if (!onValueChange) return + if (deepEqual(activeBar, { ...data.payload, value: data.value })) { + setActiveLegend(undefined) + setActiveBar(undefined) + onValueChange?.(null) + } else { + setActiveLegend(data.tooltipPayload?.[0]?.dataKey) + setActiveBar({ + ...data.payload, + value: data.value, + }) + onValueChange?.({ + eventType: "bar", + categoryClicked: data.tooltipPayload?.[0]?.dataKey, + ...data.payload, + }) + } + } + + function onCategoryClick(dataKey: string) { + if (!hasOnValueChange) return + if (dataKey === activeLegend && !activeBar) { + setActiveLegend(undefined) + onValueChange?.(null) + } else { + setActiveLegend(dataKey) + onValueChange?.({ + eventType: "category", + categoryClicked: dataKey, + }) + } + setActiveBar(undefined) + } + + return ( +
    + + { + setActiveBar(undefined) + setActiveLegend(undefined) + onValueChange?.(null) + } + : undefined + } + margin={{ + bottom: xAxisLabel ? 30 : undefined, + left: yAxisLabel ? 20 : undefined, + right: yAxisLabel ? 5 : undefined, + top: 5, + }} + stackOffset={type === "percent" ? "expand" : undefined} + layout={layout} + barCategoryGap={barCategoryGap} + > + {showGridLines ? ( + + ) : null} + + {xAxisLabel && ( + + )} + + + {yAxisLabel && ( + + )} + + { + const cleanPayload: TooltipProps["payload"] = payload + ? payload.map((item: any) => ({ + category: item.dataKey, + value: item.value, + index: item.payload[index], + color: categoryColors.get( + item.dataKey, + ) as AvailableChartColorsKeys, + type: item.type, + payload: item.payload, + })) + : [] + + if ( + tooltipCallback && + (active !== prevActiveRef.current || + label !== prevLabelRef.current) + ) { + tooltipCallback({ active, payload: cleanPayload, label }) + prevActiveRef.current = active + prevLabelRef.current = label + } + + return showTooltip && active ? ( + CustomTooltip ? ( + + ) : ( + + ) + ) : null + }} + /> + {showLegend ? ( + + ChartLegend( + { payload }, + categoryColors, + setLegendHeight, + activeLegend, + hasOnValueChange + ? (clickedLegendItem: string) => + onCategoryClick(clickedLegendItem) + : undefined, + enableLegendSlider, + legendPosition, + yAxisWidth, + ) + } + /> + ) : null} + {categories.map((category) => ( + + renderShape(props, activeBar, activeLegend, layout) + } + onClick={onBarClick} + /> + ))} + + +
    + ) + }, +) + +BarChart.displayName = "BarChart" + +export { BarChart, type BarChartEventProps, type BarChartProps, type TooltipProps } diff --git a/src/components/tremor/chart-colors.ts b/src/components/tremor/chart-colors.ts new file mode 100644 index 0000000..6590099 --- /dev/null +++ b/src/components/tremor/chart-colors.ts @@ -0,0 +1,105 @@ +// ============================================================ +// Tremor chartColors [v0.1.0] — copied from tremorlabs/tremor. +// +// The Tremor charts source uses these helpers to map a category +// name to a stable Tailwind color class (`bg-violet-500`, +// `fill-blue-500`, …). Tremor's "Raw" distribution is copy-paste +// — there is no `@tremor/raw` npm package — so the canonical +// approach is to vendor the file unchanged and customise locally. +// +// Source: https://github.com/tremorlabs/tremor/blob/main/src/utils/chartColors.ts +// License: Apache 2.0 (Tremor) +// ============================================================ + +export type ColorUtility = "bg" | "stroke" | "fill" | "text" + +export const chartColors = { + blue: { + bg: "bg-blue-500", + stroke: "stroke-blue-500", + fill: "fill-blue-500", + text: "text-blue-500", + }, + emerald: { + bg: "bg-emerald-500", + stroke: "stroke-emerald-500", + fill: "fill-emerald-500", + text: "text-emerald-500", + }, + violet: { + bg: "bg-violet-500", + stroke: "stroke-violet-500", + fill: "fill-violet-500", + text: "text-violet-500", + }, + amber: { + bg: "bg-amber-500", + stroke: "stroke-amber-500", + fill: "fill-amber-500", + text: "text-amber-500", + }, + gray: { + bg: "bg-gray-500", + stroke: "stroke-gray-500", + fill: "fill-gray-500", + text: "text-gray-500", + }, + cyan: { + bg: "bg-cyan-500", + stroke: "stroke-cyan-500", + fill: "fill-cyan-500", + text: "text-cyan-500", + }, + pink: { + bg: "bg-pink-500", + stroke: "stroke-pink-500", + fill: "fill-pink-500", + text: "text-pink-500", + }, + lime: { + bg: "bg-lime-500", + stroke: "stroke-lime-500", + fill: "fill-lime-500", + text: "text-lime-500", + }, + fuchsia: { + bg: "bg-fuchsia-500", + stroke: "stroke-fuchsia-500", + fill: "fill-fuchsia-500", + text: "text-fuchsia-500", + }, +} as const satisfies { + [color: string]: { + [key in ColorUtility]: string + } +} + +export type AvailableChartColorsKeys = keyof typeof chartColors + +export const AvailableChartColors: AvailableChartColorsKeys[] = Object.keys( + chartColors, +) as Array + +export const constructCategoryColors = ( + categories: string[], + colors: AvailableChartColorsKeys[], +): Map => { + const categoryColors = new Map() + categories.forEach((category, index) => { + categoryColors.set(category, colors[index % colors.length]) + }) + return categoryColors +} + +export const getColorClassName = ( + color: AvailableChartColorsKeys, + type: ColorUtility, +): string => { + const fallbackColor = { + bg: "bg-gray-500", + stroke: "stroke-gray-500", + fill: "fill-gray-500", + text: "text-gray-500", + } + return chartColors[color]?.[type] ?? fallbackColor[type] +} diff --git a/src/components/tremor/get-y-axis-domain.ts b/src/components/tremor/get-y-axis-domain.ts new file mode 100644 index 0000000..b95424e --- /dev/null +++ b/src/components/tremor/get-y-axis-domain.ts @@ -0,0 +1,13 @@ +// Tremor getYAxisDomain — copied from tremorlabs/tremor. +// License: Apache 2.0 (Tremor). +// Source: https://github.com/tremorlabs/tremor/blob/main/src/utils/getYAxisDomain.ts + +export const getYAxisDomain = ( + autoMinValue: boolean, + minValue: number | undefined, + maxValue: number | undefined, +) => { + const minDomain = autoMinValue ? "auto" : (minValue ?? 0) + const maxDomain = maxValue ?? "auto" + return [minDomain, maxDomain] +} diff --git a/src/components/tremor/use-on-window-resize.ts b/src/components/tremor/use-on-window-resize.ts new file mode 100644 index 0000000..81d9019 --- /dev/null +++ b/src/components/tremor/use-on-window-resize.ts @@ -0,0 +1,17 @@ +// Tremor useOnWindowResize hook — copied from tremorlabs/tremor. +// License: Apache 2.0 (Tremor). +// Source: https://github.com/tremorlabs/tremor/blob/main/src/hooks/useOnWindowResize.ts + +import * as React from "react" + +export const useOnWindowResize = (handler: () => void) => { + React.useEffect(() => { + const handleResize = () => { + handler() + } + handleResize() + window.addEventListener("resize", handleResize) + + return () => window.removeEventListener("resize", handleResize) + }, [handler]) +}