feat(web): rebuild analytics charts on bklit-ui and fix avg/day#1093
Conversation
Fix the home + analytics "avg/day" stat, which divided the all-time total by a fixed 30-day window (~1667); it now divides by the actual tracked span so both pages agree (~239/day). Remove the pre-Convex "legacy" archive callout from the analytics header. Migrate every analytics chart from evilcharts to bklit-ui (shadcn @bklit registry, composable visx + motion) behind two thin wrappers (TrendAreaChart, CategoryBarChart). Charts are themed through the --chart-* CSS vars mapped onto site tokens (primary / border / muted-foreground / popover) for a minimal terminal look that adapts to light and dark automatically. Horizontal bars get a configurable left gutter so category names stay readable. Also fixes a vendored shimmering-text import path and adds @types/d3-array. Removes the evilcharts library and evil-chart-utils.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5adca81252
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
WalkthroughThis PR replaces the legacy EvilCharts analytics stack with new bklit chart primitives, wrappers, and shared utilities. It also simplifies analytics stats/header wiring, updates chart theme/dependency setup, and removes the old EvilCharts modules. ChangesAnalytics Dashboard & Chart Migration
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint install failed. For unrecoverable errors, disable the tool in CodeRabbit configuration. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 13
♻️ Duplicate comments (1)
apps/web/src/components/charts/tooltip/date-ticker.tsx (1)
6-8: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
COMPACT_TICKER_THRESHOLDduplicated inchart-tooltip.tsx.See companion comment in
chart-tooltip.tsx(line 117) —dateLabels.length > 60there hardcodes the same value independently of this constant. Exporting this constant and importing it inchart-tooltip.tsxwould keep the two behaviors in sync.
🧹 Nitpick comments (18)
apps/web/src/app/(home)/analytics/_components/bklit-charts.tsx (1)
55-60: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueDuplicate color-fallback logic between tooltip and render paths.
s.color ?? (s.line ? MUTED : ACCENT)is computed independently inrows(Line 57) and again in theseries.maprender (Line 74). Any future change to one branch risks silently desyncing tooltip colors from rendered series colors.♻️ Proposed dedup
+ const colorFor = (s: ChartSeries) => s.color ?? (s.line ? MUTED : ACCENT); + const rows = (point: Row): TooltipRow[] => series.map((s) => ({ - color: s.color ?? (s.line ? MUTED : ACCENT), + color: colorFor(s), label: s.label, value: valueFormat(num(point[s.key])), })); ... {series.map((s) => { - const color = s.color ?? (s.line ? MUTED : ACCENT); + const color = colorFor(s);Also applies to: 73-87
apps/web/src/components/charts/x-axis.tsx (1)
618-625: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winKeep label keys stable across position changes.
Line 624 includes
item.x, so labels remount when the scale changes and thelefttransition onXAxisLabelcannot animate.Proposed fix
- key={`${item.date.getTime()}-${item.x}`} + key={`${item.date.getTime()}-${item.label}`}apps/web/src/components/charts/tooltip/date-ticker.tsx (1)
23-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winHardcoded zinc palette bypasses the chart theming CSS variables.
TooltipBox,TooltipIndicator, andTooltipDotall theme viachartCssVars(mapped to site tokens per the PR description), but the ticker pill uses hardcodedbg-zinc-900/dark:bg-zinc-100/text-whiteclasses. This is inconsistent with the rest of the tooltip subsystem's theming strategy and won't automatically pick up custom site tokens.Also applies to: 96-96
apps/web/src/components/charts/tooltip/tooltip-box.tsx (1)
96-101: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winFlip/position formula duplicated in render body and
useLayoutEffect.The same
shouldFlipX/targetX/targetYcomputation is written twice (once against the stale ref defaults, once against freshly-measured values). Extracting a small helper reduces the risk of the two copies drifting apart on a future edit.♻️ Suggested extraction
+function computeFlipPosition(x: number, y: number, w: number, h: number, containerWidth: number, containerHeight: number, offset: number) { + const flip = x + w + offset > containerWidth; + const targetX = flip ? x - offset - w : x + offset; + const targetY = Math.max(offset, Math.min(y - h / 2, containerHeight - h - offset)); + return { flip, targetX, targetY }; +}Also applies to: 126-130
apps/web/src/components/charts/tooltip/tooltip-indicator.tsx (1)
97-97: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winDefault
gradientIdisn't guaranteed unique across chart instances.
gradientIddefaults to a fixed string andchart-tooltip.tsxnever overrides it per chart. With multiple charts mounted on the analytics page, any scenario where two indicators are visible simultaneously (multi-touch, or future simultaneous-hover UX) will collide on the same SVGid, causing one gradient to silently override the other.🛡️ Suggested fix: use React's `useId()` for a guaranteed-unique default
+import { useId } from "react"; ... - gradientId = "tooltip-indicator-gradient", + gradientId, ... + const generatedId = useId(); + const resolvedGradientId = gradientId ?? `tooltip-indicator-gradient-${generatedId}`;(then use
resolvedGradientIdin place ofgradientIdfor the<linearGradient id>/fill="url(#...)"references)apps/web/src/components/shimmering-text.tsx (1)
39-61: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueMinor:
createCharVariantsresult isn't actually memoized per character.
useCallbackonly stabilizes the function identity; callingcreateCharVariants(index)inside the render loop still allocates a freshVariantsobject per character on every render. For long strings, consideruseMemoover the fulltextto build the array once. Given typical short shimmer labels, this is unlikely to matter in practice.apps/web/src/components/charts/static-chart-preview-context.tsx (1)
8-12: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueProvider hardcodes
value={true}— no way to toggle off within nested subtree.If a future consumer needs to conditionally re-enable the reveal clip-path inside a static-preview subtree, there's no prop escape hatch. Given the current single-purpose usage (always-on wrapper), this is acceptable, but consider accepting an optional
enabledprop for flexibility.apps/web/src/components/charts/area-chart.tsx (1)
74-107: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueComponent-detection heuristic is fragile for wrapped components.
extractAreaConfigsrelies onchild.type === Area/displayNamechecks that assumeArea/PatternAreaare plain function components. If either is ever wrapped inmemo()orforwardRef(),typeof child.type === "function"becomes false andcomponentNameresolution silently breaks, falling back only to thedataKeyduck-typing check (which also mis-classifies any other child exposing a stringdataKeyprop as anArea).apps/web/src/components/charts/bar-y-axis.tsx (1)
93-101: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueLabel decimation via fixed
stepcan hide the last label / produce uneven spacing.
allLabels.filter((_, i) => i % step === 0)always keeps index 0 but may drop the final label if(length - 1) % step !== 0, which can look inconsistent for a bar-axis where the boundary categories are often most relevant to show.apps/web/src/components/charts/chart-defs.ts (1)
51-61: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
collectChartDefsChildrendoesn't flattenFragments, unlike similar traversal helpers.Other chart-child traversal utilities in this PR (
forEachChartChildinchart-child-passthrough.ts,extractProjectionLineConfigsinprojection-config.ts) explicitly recurse intoFragmentchildren because "studio often groups layers in<>...</>". This function only does a flatChildren.forEach, so pattern/gradient defs wrapped in a Fragment will be silently dropped, breaking their fills.♻️ Proposed fix to flatten Fragments
export function collectChartDefsChildren(children: ReactNode): ReactElement[] { const defNodes: ReactElement[] = []; - Children.forEach(children, (child) => { - if (isValidElement(child) && isChartDefsComponent(child)) { - defNodes.push(child); - } - }); + const visit = (nodes: ReactNode) => { + Children.forEach(nodes, (child) => { + if (!isValidElement(child)) return; + if (child.type === Fragment) { + visit((child.props as { children?: ReactNode }).children); + return; + } + if (isChartDefsComponent(child)) { + defNodes.push(child); + } + }); + }; + visit(children); return defNodes; }apps/web/src/components/charts/chart-child-passthrough.ts (1)
67-103: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winComponent-name resolution logic duplicated across files.
The
typeof child.type === "function" ? childType.displayName || childType.name || "" : ""pattern is repeated here (3 times) and reimplemented again inchart-defs.ts(getChartChildComponentName) andprojection-config.ts(getChildComponentName). Consider extracting a single shared helper (e.g. exportinggetChartChildComponentNamefrom a common module) that all three files import, so any future fix (e.g.memo/forwardRefsupport) only needs to land once.apps/web/src/components/charts/chart-config-context.tsx (1)
50-81: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winNo unit tests for the interpolation branches.
resolveTooltipBoxMotionhas non-trivial piecewise interpolation (three branches:damping === 0,< default,> default). This is a good candidate for small unit tests covering each branch and the clamp boundary, similar to howhighlight-segment-bounds.tsis split out for testability.apps/web/src/components/charts/chart-context.tsx (1)
236-350: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoffManually duplicated field lists (object + deps, ×2) are fragile to drift.
Every field in
ChartContextValuemust be kept in sync across 4 places: the interface, thestableobject literal, thestabledeps array, and (for hover fields) thehoverobject/deps. Today they match, but adding a new field toChartContextValuewithout updating all four spots silently produces a stale stable/hover slice (a value present in the type but missing fromstable's object or deps will just beundefinedfor consumers, with no compile error). Consider a small helper (e.g. picking keys via a shared array, or a shallow-equal-based memo utility) to reduce the number of places that must be kept in sync.apps/web/src/components/charts/use-chart-phase-orchestrator.ts (1)
19-174: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚖️ Poor tradeoffConsider splitting the forward/reverse transition branches into named helpers.
The main transition
useEffect(lines 42-78) already carries abiome-ignorefor cognitive complexity. Extracting the"ready"<-"loading"and"loading"<-"ready"branches into two small named functions (e.g.enterReady(...),enterLoading(...)) would make the state machine easier to follow without changing behavior.apps/web/src/components/charts/use-highlight-segment.ts (1)
48-56: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winRef mutation + imperative motion-value updates during render is fragile under concurrent/Strict-Mode re-renders.
wasActive.currentis both read and written directly in the render body, and used to decide whether to call.jump()vs.set(). Under React Strict Mode dev double-invocation (or any interrupted/replayed render), the ref write from a discarded render pass persists, so the second invocation can seewasActive.currentalready flipped and take the wrong branch — e.g. using.set()(eases in) instead of.jump()(snaps instantly) on the very first hover after mount. React's own guidance is to avoid mutating refs during render except for lazy state init; prefer deriving "previous value" via the sanctioneduseState-based pattern (compare against state, callsetStateconditionally during render) instead of a raw ref mutation.♻️ Suggested pattern
- const wasActive = useRef(false); - if (bounds.isActive && !wasActive.current) { - xSpring.jump(bounds.x); - widthSpring.jump(bounds.width); - } else { - xSpring.set(bounds.x); - widthSpring.set(bounds.width); - } - wasActive.current = bounds.isActive; + const [prevActive, setPrevActive] = useState(bounds.isActive); + if (prevActive !== bounds.isActive) { + setPrevActive(bounds.isActive); + } + if (bounds.isActive && !prevActive) { + xSpring.jump(bounds.x); + widthSpring.jump(bounds.width); + } else { + xSpring.set(bounds.x); + widthSpring.set(bounds.width); + }apps/web/src/components/charts/use-animated-y-domains.ts (1)
194-225: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winPrefer
domainsEqualoverJSON.stringifysignature for change detection.
JSON.stringifyis sensitive to object key insertion order, so equal-value domain maps built with a different key order would be spuriously treated as "changed" (or vice versa).y-domain-utils.tsalready exportsdomainsEqual()for order-independent comparison.♻️ Proposed refactor
- const targetSignature = JSON.stringify(targetByAxis); - const prevTargetSignatureRef = useRef(targetSignature); + const prevTargetRef = useRef(targetByAxis); useEffect(() => { const inLivePhase = chartPhase === "ready" || chartPhase === "revealing"; if (!inLivePhase) { - prevTargetSignatureRef.current = targetSignature; + prevTargetRef.current = targetByAxis; return; } - if (prevTargetSignatureRef.current === targetSignature) { + if (domainsEqual(prevTargetRef.current, targetByAxis)) { return; } - prevTargetSignatureRef.current = targetSignature; + prevTargetRef.current = targetByAxis; ... - }, [chartPhase, durationMs, enabled, reducedMotion, targetSignature, tweenOnTargetChange]); + }, [chartPhase, durationMs, enabled, reducedMotion, targetByAxis, tweenOnTargetChange]);apps/web/src/components/charts/use-chart-interaction.ts (2)
70-130: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDuplicated nearest-point resolution logic.
resolveTooltipFromX(lines 72-90) andresolveIndexFromX(lines 113-127) implement the identical bisect + nearest-neighbor-by-time comparison. Consider extracting a sharedresolveNearestIndex(pixelX)helper that both build on, to avoid the two implementations drifting apart.♻️ Sketch
const resolveNearestIndex = useCallback( (pixelX: number): number => { const x0 = xScale.invert(pixelX); const index = bisectDate(data, x0, 1); const d0 = data[index - 1]; const d1 = data[index]; if (!d0) return 0; if (d1) { const d0Time = xAccessor(d0).getTime(); const d1Time = xAccessor(d1).getTime(); if (x0.getTime() - d0Time > d1Time - x0.getTime()) return index; } return index - 1; }, [xScale, data, xAccessor, bisectDate], );Both
resolveTooltipFromXandresolveIndexFromXcan then call this.
220-296: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTwo-touch pinch-selection block duplicated between
handleTouchStartandhandleTouchMove.Lines 233-251 and 276-292 are nearly identical (compute
x0/x1, derivestartX/endX, callresolveIndexFromXtwice,setSelection). Worth extracting a smallbuildSelectionFromTouches(event)helper to keep the two handlers in sync going forward.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 1b2e5db0-e22e-40cb-a90f-ae79ab2765a1
⛔ Files ignored due to path filters (1)
bun.lockis excluded by!**/*.lock
📒 Files selected for processing (92)
apps/web/components.jsonapps/web/package.jsonapps/web/src/app/(home)/_components/stats-section.tsxapps/web/src/app/(home)/analytics/_components/analytics-header.tsxapps/web/src/app/(home)/analytics/_components/analytics-page.tsxapps/web/src/app/(home)/analytics/_components/bklit-charts.tsxapps/web/src/app/(home)/analytics/_components/dev-environment-charts.tsxapps/web/src/app/(home)/analytics/_components/evil-chart-utils.tsapps/web/src/app/(home)/analytics/_components/metrics-cards.tsxapps/web/src/app/(home)/analytics/_components/preference-chart-card.tsxapps/web/src/app/(home)/analytics/_components/timeline-charts.tsxapps/web/src/app/(home)/analytics/analytics-client.tsxapps/web/src/app/global.cssapps/web/src/components/charts/animation.tsapps/web/src/components/charts/area-chart-loading.tsxapps/web/src/components/charts/area-chart.tsxapps/web/src/components/charts/area-gradient-defs.tsxapps/web/src/components/charts/area.tsxapps/web/src/components/charts/bar-chart-loading.tsxapps/web/src/components/charts/bar-chart.tsxapps/web/src/components/charts/bar-depth-geometry.tsapps/web/src/components/charts/bar-x-axis.tsxapps/web/src/components/charts/bar-y-axis.tsxapps/web/src/components/charts/bar.tsxapps/web/src/components/charts/chart-child-passthrough.tsapps/web/src/components/charts/chart-config-context.tsxapps/web/src/components/charts/chart-context.tsxapps/web/src/components/charts/chart-defs.tsapps/web/src/components/charts/chart-formatters.tsapps/web/src/components/charts/chart-legend-hover.tsxapps/web/src/components/charts/chart-loading-label.tsxapps/web/src/components/charts/chart-phase.tsapps/web/src/components/charts/chart-reveal-clip.tsxapps/web/src/components/charts/dash-tail-stroke.tsxapps/web/src/components/charts/decimate-time-series.tsapps/web/src/components/charts/fade-edges.tsapps/web/src/components/charts/filter-data-by-x-domain.tsapps/web/src/components/charts/generate-chart-skeleton-data.tsapps/web/src/components/charts/grid.tsxapps/web/src/components/charts/highlight-segment-bounds.tsapps/web/src/components/charts/highlight-segment.tsxapps/web/src/components/charts/indicator-fade.tsapps/web/src/components/charts/line-loading-pulse.tsxapps/web/src/components/charts/line-loading-timing.tsapps/web/src/components/charts/loading-sweep.tsxapps/web/src/components/charts/motion-utils.tsapps/web/src/components/charts/path-stroke-utils.tsapps/web/src/components/charts/pattern-area.tsxapps/web/src/components/charts/projection-config.tsapps/web/src/components/charts/projection-utils.tsapps/web/src/components/charts/reference-area-config.tsapps/web/src/components/charts/reference-area-registration-context.tsxapps/web/src/components/charts/series-bar-layout.tsapps/web/src/components/charts/series-dash-tail-overlay.tsxapps/web/src/components/charts/series-highlight-layer.tsxapps/web/src/components/charts/series-hover-dim.tsxapps/web/src/components/charts/series-markers.tsxapps/web/src/components/charts/series-point-marker.tsxapps/web/src/components/charts/static-chart-preview-context.tsxapps/web/src/components/charts/time-series-chart-shell.tsxapps/web/src/components/charts/tooltip/chart-tooltip.tsxapps/web/src/components/charts/tooltip/date-ticker.tsxapps/web/src/components/charts/tooltip/index.tsapps/web/src/components/charts/tooltip/tooltip-box.tsxapps/web/src/components/charts/tooltip/tooltip-content.tsxapps/web/src/components/charts/tooltip/tooltip-dot.tsxapps/web/src/components/charts/tooltip/tooltip-indicator.tsxapps/web/src/components/charts/use-animated-y-domains.tsapps/web/src/components/charts/use-chart-interaction.tsapps/web/src/components/charts/use-chart-phase-orchestrator.tsapps/web/src/components/charts/use-enter-complete.tsapps/web/src/components/charts/use-grid-shimmer.tsapps/web/src/components/charts/use-highlight-segment.tsapps/web/src/components/charts/use-mount-progress.tsapps/web/src/components/charts/use-scheduled-tooltip.tsapps/web/src/components/charts/x-axis.tsxapps/web/src/components/charts/y-axis-scales.tsapps/web/src/components/charts/y-axis-ticks.tsapps/web/src/components/charts/y-domain-utils.tsapps/web/src/components/evilcharts/charts/area-chart.tsxapps/web/src/components/evilcharts/charts/bar-chart.tsxapps/web/src/components/evilcharts/charts/line-chart.tsxapps/web/src/components/evilcharts/charts/pie-chart.tsxapps/web/src/components/evilcharts/charts/radar-chart.tsxapps/web/src/components/evilcharts/charts/radial-chart.tsxapps/web/src/components/evilcharts/ui/background.tsxapps/web/src/components/evilcharts/ui/chart.tsxapps/web/src/components/evilcharts/ui/dot.tsxapps/web/src/components/evilcharts/ui/evil-brush.tsxapps/web/src/components/evilcharts/ui/legend.tsxapps/web/src/components/evilcharts/ui/tooltip.tsxapps/web/src/components/shimmering-text.tsx
💤 Files with no reviewable changes (14)
- apps/web/src/components/evilcharts/ui/dot.tsx
- apps/web/src/components/evilcharts/ui/legend.tsx
- apps/web/src/components/evilcharts/ui/evil-brush.tsx
- apps/web/src/components/evilcharts/ui/chart.tsx
- apps/web/src/components/evilcharts/charts/line-chart.tsx
- apps/web/src/components/evilcharts/ui/tooltip.tsx
- apps/web/src/components/evilcharts/ui/background.tsx
- apps/web/src/components/evilcharts/charts/radar-chart.tsx
- apps/web/src/components/evilcharts/charts/radial-chart.tsx
- apps/web/src/components/evilcharts/charts/area-chart.tsx
- apps/web/src/app/(home)/analytics/_components/evil-chart-utils.ts
- apps/web/src/components/evilcharts/charts/pie-chart.tsx
- apps/web/src/components/evilcharts/charts/bar-chart.tsx
- apps/web/src/app/(home)/analytics/_components/analytics-page.tsx
- denser horizontal preference bars (30px/row instead of 38) - drop leftover min-h wrappers that padded empty space below short charts - lay CLI versions in a single 4-column row instead of a 2x2 wall
bklit's AreaChart hardcoded clipPathId="chart-area-grow-clip", so the two area charts on the analytics page (metrics sparkline + daily timeline) emitted duplicate document-scoped SVG ids and could resolve each other's reveal clip. Derive a per-instance id from useId, matching how the rest of the chart library already generates ids.
Summary
Fixes inaccurate analytics numbers and rebuilds every chart on the analytics page using bklit-ui, themed to match the site (Catppuccin, minimal terminal look, auto light/dark).
Analytics accuracy
avg/daybug fixed. The home stats card and the analytics page divided the all-time project total by a fixed 30-day window (50017 / 30 ≈ 1667). It now divides by the actual tracked calendar span (firstDate→lastDate ≈ 209 days) → ~239/day, and both pages now agree. Verified against live prod Convex data.total: 55434, avgPerDay: 326.1) from the analytics header, and threaded the prop removal throughanalytics-client → analytics-page → analytics-header.Charts → bklit-ui
@bklitshadcn registry and installed the area + bar chart families (composable, built on@visx/*+motion). Vendored underapps/web/src/components/charts.analytics/_components/bklit-charts.tsx:TrendAreaChart(date-x time series) andCategoryBarChart(categorical, with a configurablelabelWidthgutter).--chart-*CSS vars mapped onto site tokens (--primary,--border,--muted-foreground,--popover) so charts adapt to light/dark automatically. Also fixed a shadcn cssVars injection bug (var(----chart-*)→var(--chart-*)).tanstack-router,postgres,drizzle,Frontend · next, …).evilchartsentirely (12 files +evil-chart-utils.ts+ registry entry).Notes / deps
@visx/*@4.0.1-alpha.0(curve, event, gradient, grid, pattern, responsive, scale, shape),d3-array, and@types/d3-array— bklit-ui is visx-based.../components/shimmering-textimport path and missing@types/d3-array.Verification
bun run check(oxfmt + oxlint) passes — 0 warnings, 0 errors.tsc --noEmitclean for the web app (only a pre-existingbun:testtest-file error remains).Summary by CodeRabbit