feat(dashboard): adopt Tremor for charts — first integration

Brings Tremor (Apache-2.0) in as the project's chart component
library and converts the response-time chart as the first
integration. Tremor is distributed copy-paste ("Tremor Raw") so
the components live under `src/components/tremor/`; the only
runtime peer dependency added is `recharts`. Two small local
adaptations to the vendored `BarChart`:

- `cx` (Tremor's `clsx`+`twMerge` helper) → our `cn` from
  `@/lib/utils`, aliased locally to keep diff vs upstream minimal.
- `@remixicon/react` legend-slider icons →
  `lucide-react`'s `ChevronLeft` / `ChevronRight`, since
  `lucide-react` is already a project dep.
- `ChartTooltipProps.label` widened from `string` to
  `string | number | undefined` to match recharts 3.x (Tremor's
  source still types it for recharts 2.x).

Why Tremor and not a wider switch (Mantine, HeroUI):
- The dashboard ships ~700 lines of hand-rolled SVG across three
  chart files — a focused charting library is the highest-ROI
  addition without touching the wider component surface.
- Tremor is Tailwind-native and built on the same Radix/Base UI
  layer as the existing `src/components/ui/*`, so it composes
  rather than displaces.
- Apache-2.0 + copy-paste distribution means no upgrade lock-in.

What this PR does
- Adds `src/components/tremor/{bar-chart.tsx,chart-colors.ts,
  get-y-axis-domain.ts,use-on-window-resize.ts}` (vendored from
  tremorlabs/tremor with the adaptations above).
- Installs `recharts@^3.8.1` (React 19 + Next.js 16 compatible).
- Rewrites `src/components/dashboard/response-time-chart.tsx`:
  drops 130 lines of bespoke SVG math (axis scaling, bar
  positioning, grid lines, tooltip rendering), reuses the same
  card/header/empty-state shell.

Deferred (own follow-up PRs)
- `pipeline-donut.tsx` — has user-customisable per-stage hex
  colours; Tremor's palette would override them. Needs either a
  small upstream patch or a `colors` override path.
- `conversations-chart.tsx` — area-chart conversion (largest of
  the three, 341 lines).
- The previous hand-rolled bar chart drew a horizontal dashed
  "target N minutes" reference line. Tremor BarChart doesn't
  expose Recharts primitives, so for now the target is promoted
  to a header pill (`target 5m`). On-chart reference line is a
  follow-up — either via a positioned overlay or an upstream
  `referenceLines` prop.

Verification
- `npm run lint`   → 0 errors (same 20 pre-existing warnings).
- `npm run typecheck` → clean.
- `npm test`       → 319/319 pass.
- `npm run build`  → succeeds, dashboard remains a static route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-05-31 14:42:10 +03:00
parent df29707abf
commit bf90c370fc
No known key found for this signature in database
7 changed files with 1400 additions and 147 deletions

318
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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 (
<section className="rounded-xl border border-slate-800 bg-slate-900">
<header className="flex items-center justify-between border-b border-slate-800 px-5 py-4">
<header className="flex items-center justify-between gap-3 border-b border-slate-800 px-5 py-4">
<div>
<h2 className="text-sm font-semibold text-white">
Average First Response Time
@ -36,25 +55,32 @@ export function ResponseTimeChart({
weekday
</p>
</div>
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
<div className="text-right text-xs">
<div className="text-slate-400">
This week:{' '}
<span className="font-medium text-white tabular-nums">
{fmt(data.thisWeekAvg)}
</span>
<div className="flex items-center gap-3 text-right text-xs">
{thresholdMinutes > 0 && (
<span className="rounded-full border border-rose-500/40 bg-rose-500/10 px-2 py-0.5 font-medium text-rose-300 tabular-nums">
target {thresholdMinutes}m
</span>
)}
{data && (data.thisWeekAvg != null || data.lastWeekAvg != null) && (
<div>
<div className="text-slate-400">
This week:{' '}
<span className="font-medium text-white tabular-nums">
{fmt(data.thisWeekAvg)}
</span>
</div>
<div className="text-slate-500">
Last week:{' '}
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
</div>
</div>
<div className="text-slate-500">
Last week:{' '}
<span className="tabular-nums">{fmt(data.lastWeekAvg)}</span>
</div>
</div>
)}
)}
</div>
</header>
<div className="p-5">
{loading || !data ? (
<Skeleton className="h-[220px] w-full" />
<Skeleton className="h-[260px] w-full" />
) : !hasData ? (
<EmptyState
icon={Clock}
@ -62,141 +88,29 @@ export function ResponseTimeChart({
hint="This chart fills in as you reply to customer messages."
/>
) : (
<Bars data={data} thresholdMinutes={thresholdMinutes} />
<BarChart
data={chartData}
index="day"
categories={[CATEGORY]}
// 'violet' maps to Tailwind's `fill-violet-500` — matches
// the brand accent the hand-rolled bars used (#7c3aed).
colors={['violet']}
valueFormatter={(value) => `${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]"
/>
)}
</div>
</section>
)
}
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 (
<svg viewBox={`0 0 ${VB_W} ${VB_H}`} className="h-[220px] w-full" role="img">
{/* Y grid */}
{ticks.map((t) => {
const y = yFor(t)
return (
<g key={t}>
<line
x1={PADDING.left}
x2={VB_W - PADDING.right}
y1={y}
y2={y}
stroke="rgb(30 41 59)"
strokeDasharray="3 3"
/>
<text
x={PADDING.left - 8}
y={y}
textAnchor="end"
dominantBaseline="middle"
className="fill-slate-500 text-[10px]"
>
{t}m
</text>
</g>
)
})}
{/* 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 && (
<g>
<line
x1={PADDING.left}
x2={VB_W - PADDING.right}
y1={yFor(thresholdMinutes)}
y2={yFor(thresholdMinutes)}
stroke="rgb(244 63 94)"
strokeDasharray="4 4"
strokeWidth={1.25}
opacity={0.8}
/>
<text
x={VB_W - PADDING.right - 4}
y={yFor(thresholdMinutes) - 4}
textAnchor="end"
className="fill-rose-300 text-[10px]"
>
target {thresholdMinutes}m
</text>
</g>
)}
{/* 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 (
<g key={i}>
<rect
x={x}
y={muted ? PADDING.top + chartH - 2 : y}
width={barW}
height={muted ? 2 : Math.max(1, h)}
rx={4}
fill={muted ? 'rgb(51 65 85)' : '#7c3aed'}
opacity={muted ? 0.6 : 1}
>
<title>
{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'})` : ''}
</title>
</rect>
<text
x={x + barW / 2}
y={VB_H - 10}
textAnchor="middle"
className="fill-slate-400 text-[11px]"
>
{DOW_SHORT_MON_FIRST[i]}
</text>
</g>
)
})}
</svg>
)
}
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
}

View File

@ -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<T>(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<keyof T>
const keys2 = Object.keys(obj2) as Array<keyof T>
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 (
<rect
x={x}
y={y}
width={width}
height={height}
opacity={
activeBar || (activeLegend && activeLegend !== name)
? deepEqual(activeBar, { ...payload, value })
? fillOpacity
: 0.3
: fillOpacity
}
/>
)
}
//#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 (
<li
className={cx(
"group inline-flex flex-nowrap items-center gap-1.5 rounded-sm px-2 py-1 whitespace-nowrap transition",
hasOnValueChange
? "cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800"
: "cursor-default",
)}
onClick={(e) => {
e.stopPropagation()
onClick?.(name, color)
}}
>
<span
className={cx(
"size-2 shrink-0 rounded-xs",
getColorClassName(color, "bg"),
activeLegend && activeLegend !== name ? "opacity-40" : "opacity-100",
)}
aria-hidden={true}
/>
<p
className={cx(
"truncate text-xs whitespace-nowrap",
"text-gray-700 dark:text-gray-300",
hasOnValueChange &&
"group-hover:text-gray-900 dark:group-hover:text-gray-50",
activeLegend && activeLegend !== name ? "opacity-40" : "opacity-100",
)}
>
{name}
</p>
</li>
)
}
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<NodeJS.Timeout | null>(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 (
<button
type="button"
className={cx(
"group inline-flex size-5 items-center truncate rounded-sm transition",
disabled
? "cursor-not-allowed text-gray-400 dark:text-gray-600"
: "cursor-pointer text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-50",
)}
disabled={disabled}
onClick={(e) => {
e.stopPropagation()
onClick?.()
}}
onMouseDown={(e) => {
e.stopPropagation()
setIsPressed(true)
}}
onMouseUp={(e) => {
e.stopPropagation()
setIsPressed(false)
}}
>
<Icon className="size-full" aria-hidden="true" />
</button>
)
}
interface LegendProps extends React.OlHTMLAttributes<HTMLOListElement> {
categories: string[]
colors?: AvailableChartColorsKeys[]
onClickLegendItem?: (category: string, color: string) => void
activeLegend?: string
enableLegendSlider?: boolean
}
type HasScrollProps = {
left: boolean
right: boolean
}
const Legend = React.forwardRef<HTMLOListElement, LegendProps>((props, ref) => {
const {
categories,
colors = AvailableChartColors,
className,
onClickLegendItem,
activeLegend,
enableLegendSlider = false,
...other
} = props
const scrollableRef = React.useRef<HTMLInputElement>(null)
const scrollButtonsRef = React.useRef<HTMLDivElement>(null)
const [hasScroll, setHasScroll] = React.useState<HasScrollProps | null>(null)
const [isKeyDowned, setIsKeyDowned] = React.useState<string | null>(null)
const intervalRef = React.useRef<NodeJS.Timeout | null>(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 (
<ol
ref={ref}
className={cx("relative overflow-hidden", className)}
{...other}
>
<div
ref={scrollableRef}
tabIndex={0}
className={cx(
"flex h-full",
enableLegendSlider
? hasScroll?.right || hasScroll?.left
? "snap-mandatory items-center overflow-auto pr-12 pl-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
: ""
: "flex-wrap",
)}
>
{categories.map((category, index) => (
<LegendItem
key={`item-${index}`}
name={category}
color={colors[index] as AvailableChartColorsKeys}
onClick={onClickLegendItem}
activeLegend={activeLegend}
/>
))}
</div>
{enableLegendSlider && (hasScroll?.right || hasScroll?.left) ? (
<>
<div
ref={scrollButtonsRef}
className={cx(
"absolute top-0 right-0 bottom-0 flex h-full items-center justify-center pr-1",
"bg-white dark:bg-gray-950",
)}
>
<ScrollButton
icon={ChevronLeft}
onClick={() => {
setIsKeyDowned(null)
scrollToTest("left")
}}
disabled={!hasScroll?.left}
/>
<ScrollButton
icon={ChevronRight}
onClick={() => {
setIsKeyDowned(null)
scrollToTest("right")
}}
disabled={!hasScroll?.right}
/>
</div>
</>
) : null}
</ol>
)
})
Legend.displayName = "Legend"
const ChartLegend = (
{ payload }: any,
categoryColors: Map<string, AvailableChartColorsKeys>,
setLegendHeight: React.Dispatch<React.SetStateAction<number>>,
activeLegend: string | undefined,
onClick?: (category: string, color: string) => void,
enableLegendSlider?: boolean,
legendPosition?: "left" | "center" | "right",
yAxisWidth?: number,
) => {
const legendRef = React.useRef<HTMLDivElement>(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 (
<div
style={{ paddingLeft: paddingLeft }}
ref={legendRef}
className={cx(
"flex items-center",
{ "justify-center": legendPosition === "center" },
{
"justify-start": legendPosition === "left",
},
{ "justify-end": legendPosition === "right" },
)}
>
<Legend
categories={filteredPayload.map((entry: any) => entry.value)}
colors={filteredPayload.map((entry: any) =>
categoryColors.get(entry.value),
)}
onClickLegendItem={onClick}
activeLegend={activeLegend}
enableLegendSlider={enableLegendSlider}
/>
</div>
)
}
//#region Tooltip
type TooltipProps = Pick<ChartTooltipProps, "active" | "payload" | "label">
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 (
<div
className={cx(
"rounded-md border text-sm shadow-md",
"border-gray-200 dark:border-gray-800",
"bg-white dark:bg-gray-950",
)}
>
<div className={cx("border-b border-inherit px-4 py-2")}>
<p
className={cx("font-medium", "text-gray-900 dark:text-gray-50")}
>
{label}
</p>
</div>
<div className={cx("space-y-1 px-4 py-2")}>
{payload.map(({ value, category, color }, index) => (
<div
key={`id-${index}`}
className="flex items-center justify-between space-x-8"
>
<div className="flex items-center space-x-2">
<span
aria-hidden="true"
className={cx(
"size-2 shrink-0 rounded-xs",
getColorClassName(color, "bg"),
)}
/>
<p
className={cx(
"text-right whitespace-nowrap",
"text-gray-700 dark:text-gray-300",
)}
>
{category}
</p>
</div>
<p
className={cx(
"text-right font-medium whitespace-nowrap tabular-nums",
"text-gray-900 dark:text-gray-50",
)}
>
{valueFormatter(value)}
</p>
</div>
))}
</div>
</div>
)
}
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<HTMLDivElement> {
data: Record<string, any>[]
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<TooltipProps>
}
const BarChart = React.forwardRef<HTMLDivElement, BarChartProps>(
(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<string | undefined>(
undefined,
)
const categoryColors = constructCategoryColors(categories, colors)
const [activeBar, setActiveBar] = React.useState<any | undefined>(undefined)
const yAxisDomain = getYAxisDomain(autoMinValue, minValue, maxValue)
const hasOnValueChange = !!onValueChange
const stacked = type === "stacked" || type === "percent"
const prevActiveRef = React.useRef<boolean | undefined>(undefined)
// Widened from `string | undefined` to match the recharts 3.x
// `label` type (see the ChartTooltipProps note below).
const prevLabelRef = React.useRef<string | number | undefined>(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 (
<div
ref={forwardedRef}
className={cx("h-80 w-full", className)}
tremor-id="tremor-raw"
{...other}
>
<ResponsiveContainer>
<RechartsBarChart
data={data}
onClick={
hasOnValueChange && (activeLegend || activeBar)
? () => {
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 ? (
<CartesianGrid
className={cx("stroke-gray-200 stroke-1 dark:stroke-gray-800")}
horizontal={layout !== "vertical"}
vertical={layout === "vertical"}
/>
) : null}
<XAxis
hide={!showXAxis}
tick={{
transform:
layout !== "vertical" ? "translate(0, 6)" : undefined,
}}
fill=""
stroke=""
className={cx(
"text-xs",
"fill-gray-500 dark:fill-gray-500",
{ "mt-4": layout !== "vertical" },
)}
tickLine={false}
axisLine={false}
minTickGap={tickGap}
{...(layout !== "vertical"
? {
padding: {
left: paddingValue,
right: paddingValue,
},
dataKey: index,
interval: startEndOnly ? "preserveStartEnd" : intervalType,
ticks: startEndOnly
? [data[0][index], data[data.length - 1][index]]
: undefined,
}
: {
type: "number",
domain: yAxisDomain as AxisDomain,
tickFormatter:
type === "percent" ? valueToPercent : valueFormatter,
allowDecimals: allowDecimals,
})}
>
{xAxisLabel && (
<Label
position="insideBottom"
offset={-20}
className="fill-gray-800 text-sm font-medium dark:fill-gray-200"
>
{xAxisLabel}
</Label>
)}
</XAxis>
<YAxis
width={yAxisWidth}
hide={!showYAxis}
axisLine={false}
tickLine={false}
fill=""
stroke=""
className={cx(
"text-xs",
"fill-gray-500 dark:fill-gray-500",
)}
tick={{
transform:
layout !== "vertical"
? "translate(-3, 0)"
: "translate(0, 0)",
}}
{...(layout !== "vertical"
? {
type: "number",
domain: yAxisDomain as AxisDomain,
tickFormatter:
type === "percent" ? valueToPercent : valueFormatter,
allowDecimals: allowDecimals,
}
: {
dataKey: index,
ticks: startEndOnly
? [data[0][index], data[data.length - 1][index]]
: undefined,
type: "category",
interval: "equidistantPreserveStart",
})}
>
{yAxisLabel && (
<Label
position="insideLeft"
style={{ textAnchor: "middle" }}
angle={-90}
offset={-15}
className="fill-gray-800 text-sm font-medium dark:fill-gray-200"
>
{yAxisLabel}
</Label>
)}
</YAxis>
<Tooltip
wrapperStyle={{ outline: "none" }}
isAnimationActive={true}
animationDuration={100}
cursor={{ fill: "#d1d5db", opacity: "0.15" }}
offset={20}
position={{
y: layout === "horizontal" ? 0 : undefined,
x: layout === "horizontal" ? undefined : yAxisWidth + 20,
}}
content={({ active, payload, label }) => {
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 ? (
<CustomTooltip
active={active}
payload={cleanPayload}
label={label}
/>
) : (
<ChartTooltip
active={active}
payload={cleanPayload}
label={label}
valueFormatter={valueFormatter}
/>
)
) : null
}}
/>
{showLegend ? (
<RechartsLegend
verticalAlign="top"
height={legendHeight}
content={({ payload }) =>
ChartLegend(
{ payload },
categoryColors,
setLegendHeight,
activeLegend,
hasOnValueChange
? (clickedLegendItem: string) =>
onCategoryClick(clickedLegendItem)
: undefined,
enableLegendSlider,
legendPosition,
yAxisWidth,
)
}
/>
) : null}
{categories.map((category) => (
<Bar
className={cx(
getColorClassName(
categoryColors.get(category) as AvailableChartColorsKeys,
"fill",
),
onValueChange ? "cursor-pointer" : "",
)}
key={category}
name={category}
type="linear"
dataKey={category}
stackId={stacked ? "stack" : undefined}
isAnimationActive={false}
fill=""
shape={(props: any) =>
renderShape(props, activeBar, activeLegend, layout)
}
onClick={onBarClick}
/>
))}
</RechartsBarChart>
</ResponsiveContainer>
</div>
)
},
)
BarChart.displayName = "BarChart"
export { BarChart, type BarChartEventProps, type BarChartProps, type TooltipProps }

View File

@ -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<AvailableChartColorsKeys>
export const constructCategoryColors = (
categories: string[],
colors: AvailableChartColorsKeys[],
): Map<string, AvailableChartColorsKeys> => {
const categoryColors = new Map<string, AvailableChartColorsKeys>()
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]
}

View File

@ -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]
}

View File

@ -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])
}