diff --git a/.changeset/query-insights-studio.md b/.changeset/query-insights-studio.md new file mode 100644 index 00000000..39e687ae --- /dev/null +++ b/.changeset/query-insights-studio.md @@ -0,0 +1,6 @@ +--- +"@prisma/studio-core": minor +--- + +Add an optional Studio Queries view backed by query-insights snapshots from the Studio BFF bridge. +Render SQL result visualizations with Studio-owned Bklit chart configs instead of Chart.js configs. diff --git a/.gitignore b/.gitignore index 73076e8a..5d687472 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ dist !.agents/skills/shadcn/ .agents/skills/shadcn/* !.agents/skills/shadcn/SKILL.md +!.agents/skills/impeccable/ +!.agents/skills/impeccable/** .playwright-cli/ .env .env.local diff --git a/Architecture/compute-preview-deploy.md b/Architecture/compute-preview-deploy.md index 206bdf08..cec403f3 100644 --- a/Architecture/compute-preview-deploy.md +++ b/Architecture/compute-preview-deploy.md @@ -45,7 +45,8 @@ publishes it into the dedicated Compute project named `studio-preview`. - If the service already exists, the helper MUST deploy a new version to that same service. - Deployments MUST use the published CLI entrypoint: - `bunx @prisma/compute-cli@latest deploy --skip-build --path deploy --entrypoint bundle/server.bundle.js --http-port 8080 --env STUDIO_DEMO_PORT=8080`. +- `bunx @prisma/compute-cli@latest deploy --skip-build --path deploy --entrypoint bundle/compute-entrypoint.js --http-port 8080`. +- The Compute artifact MUST include `bundle/compute-entrypoint.js`, which defaults `STUDIO_DEMO_PORT` to `8080` before importing `bundle/server.bundle.js`. Preview deploys MUST NOT pass runtime environment variables through the Compute CLI while its deploy API rejects the CLI's `envVars` request key. ## PR Feedback diff --git a/Architecture/navigation-url-state.md b/Architecture/navigation-url-state.md index 5f43a21f..2dd63b4e 100644 --- a/Architecture/navigation-url-state.md +++ b/Architecture/navigation-url-state.md @@ -8,7 +8,7 @@ Navigation state MUST be URL-driven and managed through `useNavigation` + Nuqs. This architecture governs: -- active Studio view (`table`, `schema`, `console`, `sql`, `stream`) +- active Studio view (`table`, `schema`, `console`, `sql`, `stream`, `queries`) - active schema/table/stream - active stream follow mode - active stream aggregation-panel visibility @@ -78,6 +78,7 @@ Adding a new URL key requires updating `StateKey` in `nuqs.ts` first. - `search`: `""` - `searchScope`: `"table"` (legacy default) - `view`: `"table"` +- `queries`: no standalone default; only meaningful when the current adapter provides query insights - `stream`: no default; only meaningful when `view=stream` - `streamFollow`: no global default in `useNavigation`; the active stream view MUST resolve an absent value to `tail` and materialize that into the hash - `aggregations`: no global default in `useNavigation`; the active stream view MUST treat an absent flag as closed and MUST NOT materialize that closed state into the hash @@ -86,9 +87,10 @@ Adding a new URL key requires updating `StateKey` in `nuqs.ts` first. When Studio is running without a database connection but with Streams enabled: - the resolved default `view` MUST become `"stream"` instead of `"table"` -- stale database-oriented views such as `table`, `schema`, `console`, and `sql` MUST resolve back to the stream view instead of trying to render database-only UI against a disabled database session +- stale database-oriented views such as `table`, `schema`, `console`, `sql`, and `queries` MUST resolve back to the stream view instead of trying to render database-only UI against a disabled database session When URL params are stale from a previous DB, invalid `schema`/`table` values MUST be resolved to valid current defaults. +When URL params contain `view=queries` but the current adapter does not provide query insights, `useNavigation` MUST resolve back to the default view and the sidebar MUST hide the Queries link. Shared table page size and infinite-scroll mode are not derived from URL defaults; they are restored through Studio UI state and then mirrored into query behavior by `usePagination`. ## Hash Adapter Contract diff --git a/Architecture/non-standard-ui.md b/Architecture/non-standard-ui.md index 62bc7a88..209278d9 100644 --- a/Architecture/non-standard-ui.md +++ b/Architecture/non-standard-ui.md @@ -145,6 +145,24 @@ It deliberately excludes: - The storage breakdowns also need collapsible ledger-style accounting boxes whose headers surface the section totals when folded shut, plus faint shared-cap annotations that sit beside right-aligned byte values and one shared cap marker spanning both Routing and Exact cache rows, which is not a stock ShadCN pattern. - No stock ShadCN pattern covers that descriptor-driven observability layout, especially when the UI must distinguish logical bytes from physical storage signals, separate search coverage from historical run indexes, hide unconfigured routing rows, and keep the remaining cost caveats explicit instead of inventing unavailable totals. +### Queries Live Table And Detail Sheet + +- Canonical component: + - [`ui/studio/views/queries/QueriesView.tsx`](ui/studio/views/queries/QueriesView.tsx) +- Closest standard ShadCN alternatives: + - `Table` + - `Card` + - `Sheet` + - `Select` + - `ToggleGroup` + - `Badge` + - `Skeleton` +- Why it stays non-standard: + - Query insights need a live operational table with provider-driven polling, a live activity chart, table filtering, sort controls, row selection, and previous/next navigation inside a detail sheet. + - The activity chart is a Studio-specific SVG timeline that derives throughput and latency from snapshot deltas, supports a bounded time-window switcher, and exposes point-level hover readings. No standard ShadCN chart primitive covers that observability display. + - The same surface conditionally embeds AI recommendations when Studio's shared `llm` hook is available, while disappearing entirely when the embedder does not provide query-insights data. + - No stock ShadCN component models that combined observability workflow, so Studio keeps a custom composite built from standard ShadCN primitives. + ## Standardization Candidates These are the current high-signal places where Studio is bypassing a plausible standard ShadCN component or composition pattern. @@ -250,11 +268,12 @@ These are the current high-signal places where Studio is bypassing a plausible s - Files: - [`ui/studio/views/sql/SqlResultVisualization.tsx`](ui/studio/views/sql/SqlResultVisualization.tsx) - [`ui/studio/views/sql/SqlView.tsx`](ui/studio/views/sql/SqlView.tsx) + - [`ui/components/charts`](ui/components/charts) - Current UI: - - Borderless Chart.js canvas injected into a `DataGrid` header row, wrapped in a custom sticky white band with centered/clamped sizing, plus a custom text-and-icon visualization trigger placed on the SQL result summary line. + - Bklit ShadCN chart primitives injected into a `DataGrid` header row, wrapped in a custom sticky background band with centered/clamped sizing, plus a custom text-and-icon visualization trigger placed on the SQL result summary line. - Plausible standard ShadCN alternative: - `Card` - `Button` - - No standard ShadCN chart primitive exists; the chart body must remain custom. + - The Bklit registry components provide the chart primitives, but the SQL result surface still needs a custom `DataGrid.getBeforeHeaderRows(...)` composition so the chart scrolls with the result grid. - Confidence: - High diff --git a/Architecture/query-insights.md b/Architecture/query-insights.md new file mode 100644 index 00000000..d831724b --- /dev/null +++ b/Architecture/query-insights.md @@ -0,0 +1,286 @@ +# Query Insights Architecture + +This document is normative for the Studio Queries view (`view=queries`) and its optional embedder bridge. + +Query insights MUST be provided by the embedder. Studio does not infer production query traffic from its own table or SQL UI; it only renders snapshots from an injected provider. + +## Scope + +This architecture governs: + +- the optional `Adapter.queryInsights` provider contract +- the BFF `query-insights` procedure +- Queries navigation visibility and URL fallback behavior +- AI recommendation visibility for query analysis +- demo-only query capture for `pnpm demo:ppg` + +## Canonical Components + +- [`data/query-insights.ts`](../data/query-insights.ts) +- [`data/adapter.ts`](../data/adapter.ts) +- [`data/bff/bff-client.ts`](../data/bff/bff-client.ts) +- [`ui/studio/context.tsx`](../ui/studio/context.tsx) +- [`ui/hooks/use-navigation.tsx`](../ui/hooks/use-navigation.tsx) +- [`ui/studio/Navigation.tsx`](../ui/studio/Navigation.tsx) +- [`ui/studio/views/queries/QueriesView.tsx`](../ui/studio/views/queries/QueriesView.tsx) +- [`ui/studio/views/queries/query-insights-ai.ts`](../ui/studio/views/queries/query-insights-ai.ts) +- [`demo/ppg-dev/query-insights.ts`](../demo/ppg-dev/query-insights.ts) +- [`demo/ppg-dev/server.ts`](../demo/ppg-dev/server.ts) + +## Embedder Contract + +- `Adapter.queryInsights` is optional. +- Studio MUST hide the `Queries` navigation item when `Adapter.queryInsights` is absent. +- If a stale URL contains `view=queries` and `Adapter.queryInsights` is absent, navigation MUST resolve back to the normal default view instead of rendering the Queries view. +- The route key is `view=queries`. Embedders MUST NOT deep-link to `view=query-insights`. +- `queryInsights` is an adapter capability. It is not a top-level Studio prop. +- Embedders that support query insights MUST provide `getSnapshot(request, options)` and return a `StudioQueryInsightsSnapshot`. +- Snapshot rows SHOULD be aggregated by a stable normalized query identity, not emitted as one row per execution. +- Snapshot rows MUST avoid raw parameter values and other sensitive payload data. The `query` field should contain parameterized SQL or another sanitized query representation. +- `tables` SHOULD contain qualified table names when the embedder can derive them. +- `rowsReturned`, `duration`, `count`, and `lastSeen` SHOULD be best-effort operational signals. Studio treats them as display and sorting data, not accounting-grade telemetry. `reads` is optional read-work telemetry and MUST NOT be used as a user-facing substitute for rows returned. +- `pollingIntervalMs` MAY be returned by the provider. A value less than or equal to `0` tells Studio not to poll automatically. + +## Lowest-Fidelity Provider + +Studio fully supports a generic SQL-only provider. A first implementation does not need Prisma ORM metadata, per-row plans, read-work estimates, grouping, or AI-specific fields. + +The minimum useful row is: + +```ts +{ + id: stableNormalizedSqlHash, + query: parameterizedOrRedactedSql, + tables: [], + count: cumulativeExecutionCount, + duration: cumulativeAverageDurationMs, + reads: 0, + rowsReturned: cumulativeRowsReturned, + lastSeen: latestObservedAtMs, + prismaQueryInfo: null, +} +``` + +Rules for this low-fidelity mode: + +- `query` MAY be plain SQL. It SHOULD be normalized and parameterized; literal values SHOULD be replaced with placeholders or redacted tokens. +- `tables` MAY be empty when table extraction is not reliable. +- `reads` MAY be `0` when the source cannot estimate physical or logical reads. +- `rowsReturned` MAY be `0` when the source cannot observe returned row counts. +- `prismaQueryInfo`, `queryId`, `groupKey`, `minDurationMs`, and `maxDurationMs` MAY be omitted. +- AI recommendations still work without `prismaQueryInfo`; the prompt falls back to SQL, table names, and metrics. User-facing AI text SHOULD call `rowsReturned` "rows returned" and SHOULD NOT call rows returned "reads". + +This is the recommended starting point for local dev providers and embedder migrations. Higher-fidelity providers can add Prisma metadata, table extraction, and better counters later without changing the Studio integration. + +## Snapshot Semantics + +- `generatedAt` is the snapshot creation time in Unix milliseconds. +- `id` MUST be stable for the same aggregated query identity within the provider's retention window. Prefer a hash of normalized SQL plus relevant Prisma metadata. Do not include wall-clock time, request IDs, or random values in `id`. +- `queryId` is optional and MAY carry an upstream query identifier when one exists. Studio does not require it for identity. +- `groupKey` is optional and MAY carry an upstream grouping key for future or host-specific grouping. Studio does not require it. +- `count` SHOULD be cumulative for the row identity over the provider's retention/reset window and SHOULD be monotonically non-decreasing while the same `id` remains present. +- `duration` SHOULD be the cumulative average duration in milliseconds for the same executions counted by `count`. +- `minDurationMs` and `maxDurationMs`, when present, SHOULD be cumulative over the same executions counted by `count`. +- `rowsReturned` SHOULD be cumulative for the same executions counted by `count`. +- `reads` SHOULD be cumulative for the same executions counted by `count`. +- `lastSeen` is the Unix millisecond timestamp of the most recent observed execution for the row. +- `limit` is a maximum number of rows Studio is asking for. Providers SHOULD apply it after filtering and sorting, usually by `lastSeen` descending. +- `since` is an optional Unix millisecond lower bound for `lastSeen`. Providers that support it SHOULD return rows with `lastSeen >= since`. Providers MAY ignore `since` and return a normal bounded snapshot. +- Provider retention SHOULD cover at least the largest Studio chart window, currently one hour, plus a small margin. If the source resets or drops counters, keep the same `id` only when the counters remain comparable; otherwise use a new identity or accept that Studio will treat the next row as a fresh series. +- Snapshot counters SHOULD NOT be pre-windowed to the selected Studio chart range. Studio derives chart deltas from successive cumulative snapshots and filters the UI window client-side. + +## Studio Client Data Paths + +Query Insights uses cumulative provider snapshots but renders selected-window UI. Studio keeps those concepts separate: + +1. The embedder returns a `StudioQueryInsightsSnapshot` through `queryInsights.getSnapshot()`, usually via the BFF `query-insights` procedure. +2. `QueriesView` stores the latest raw snapshot rows in React state for current SQL text, table names, Prisma metadata, and detail-sheet context. +3. `QueriesView` also keeps a provider-keyed in-memory cache with the latest snapshot totals, latest row by `id`, global chart samples, and per-query metric samples. This cache survives leaving and returning to the `Queries` view while the same provider object is mounted. +4. The first snapshot creates `context` samples from each row's `lastSeen` timestamp. A context sample represents one recent observed execution for visual and table context. It carries average latency and average per-execution counters, but it does not carry a live `queriesPerSecond` value. +5. Later snapshots with a strictly newer `generatedAt` create `measured` samples. Studio compares each current row with the previous row for the same `id`; continuous counters become deltas, and reset counters with a newly advanced `lastSeen` are treated as fresh measured activity. Snapshots with stale or equal `generatedAt` do not create samples. +6. Global chart samples are derived from the same measured per-query deltas used for table rows. This keeps chart throughput, chart latency, table execution counts, and table row counters aligned. +7. Studio prunes both global and per-query samples to the largest selectable chart window, currently one hour. + +The chart uses those samples as follows: + +- The x-axis visible range ends at the latest retained global sample and extends left by the selected range. +- Throughput line data comes only from `measured` samples with a known `queriesPerSecond`. Newly observed executions are plotted in one-second buckets at their reported `lastSeen` time so delayed polling does not dilute a burst into the whole snapshot interval. Context samples do not draw blue throughput points because no rate interval is known. +- Average-latency line data comes from `measured` samples. Measured samples with no executions render as a zero-latency baseline so throughput and latency segments start, stop, and bridge short idle periods consistently. Context samples render as isolated green latency points. +- The header `Queries/s` value is the measured execution delta divided by measured elapsed seconds in the selected range. If there is no measured interval yet, Studio shows `n/a` instead of `0/s`. +- The header `Avg latency` value uses measured executions in the selected range. Zero-execution measured baseline samples are excluded from that average. If no measured executions exist but context latency points are visible, it falls back to those context samples. If neither exists, Studio shows `n/a`. + +The query table uses the per-query samples as follows: + +- It groups `QueryMetricSample` rows by query `id` inside the selected chart range. +- `Executions`, `Rows Returned`, `reads`, `duration`, and `lastSeen` are recomputed from that group and are never read directly from cumulative provider counters for the visible row. +- First-snapshot context rows show one representative execution with average per-execution counters so a fresh view can still show useful rows without pretending the whole cumulative provider history happened inside the selected window. +- The detail sheet and AI recommendation prompt use the same selected-window query row shown in the table. + +## View Interaction Contract + +- The `Queries` view MUST render under the Studio `Visualizer` navigation item and MUST NOT be available when `Adapter.queryInsights` is absent. +- The top-level description SHOULD explain that Studio monitors database activity and helps identify poorly performing queries. +- The activity chart MUST show `Queries/s` and `Avg latency` summaries, share one selected range with the query table, and expose `1m`, `5m`, `15m`, and `1h` range controls. +- The query table SHOULD include `Latency`, `Query`, `Executions`, `Rows Returned`, and `Last Seen` columns. `Rows Returned` is the user-facing label for `rowsReturned`; `reads` remains an optional internal read-work estimate and MAY be used in AI prompts only under that wording. +- When AI recommendations are available, the query table SHOULD add an `Analysis` column with queued, running, manual analyze, and completed severity states. +- The table filter SHOULD use touched tables derived from the selected-window rows, not from stale cumulative provider data. +- Sorting SHOULD operate on selected-window row values, not provider cumulative counters. +- Clicking a query row SHOULD open a detail sheet with SQL, touched tables, selected-window metrics, and optional recommendations. +- The detail sheet SHOULD support previous/next navigation through the currently visible sorted table rows. +- Pause/resume SHOULD stop and restart polling the provider without clearing already retained local samples. + +## @prisma/sqlcommenter-query-insights Mapping + +When SQL includes `@prisma/sqlcommenter-query-insights` metadata, embedders SHOULD remove the `prismaQuery` tag from the displayed `query` and map it into `prismaQueryInfo`. + +Canonical tag shape: + +```sql +/*prismaQuery='User.findMany:BASE64URL_JSON_PAYLOAD'*/ +/*prismaQuery='queryRaw'*/ +/*prismaQuery='executeRaw'*/ +``` + +Mapping rules: + +- URL-decode the `prismaQuery` value before parsing. +- If the decoded value has no `:`, treat it as a raw Prisma operation: `{ action: decoded, isRaw: true }`. +- If the decoded value has a `:`, split at the first colon. The left side is the operation prefix and the right side is a base64url-encoded JSON payload. +- Split the operation prefix at the first `.`. The left side is `model`; the right side is `action`. If there is no `.`, omit `model` and use the whole prefix as `action`. +- Decode the base64url payload as JSON. It MAY be an object or an array of objects for compacted batches. +- Recursively redact parameter sentinel objects before storing the payload. Any object with `{ "$type": "Param" }` MUST become a redacted placeholder such as `"<>"`. +- If payload decoding fails, keep `model`, `action`, and `isRaw: false`, but omit `payload`. +- If the tag is malformed and no action can be determined, omit `prismaQueryInfo`. +- Remove the `prismaQuery` key from the SQL shown in `query`. Other SQL comments SHOULD also be removed unless the embedder knows they contain no sensitive data. + +Examples: + +```ts +// /*prismaQuery='User.findMany:eyJ3aGVyZSI6e319'*/ +prismaQueryInfo = { + action: "findMany", + isRaw: false, + model: "User", + payload: { where: {} }, +}; + +// /*prismaQuery='queryRaw'*/ +prismaQueryInfo = { + action: "queryRaw", + isRaw: true, +}; +``` + +## Traffic Exclusion And Sanitization + +Production Query Insights MUST represent the workload the embedder wants users to inspect, not Studio's own implementation traffic. + +Embedders SHOULD exclude: + +- Query Insights snapshot requests themselves. +- Studio schema introspection, table-list, column-list, enum/type lookup, and relation discovery queries. +- SQL lint, `EXPLAIN`, parse/plan, prepared-statement validation, and dry-run queries. +- Studio table browsing, pagination, sorting, filtering, insert, update, delete, and refetch queries unless the product explicitly wants Studio-originated traffic in the list. +- Metadata and health queries such as `current_setting`, timezone/version checks, `pg_catalog`, `information_schema`, `pg_stat_*`, database size, privilege setup, extension setup, and connection/status probes. +- Demo seed/bootstrap queries, migration/setup queries, stream diagnostics, usage/telemetry queries, and host control-plane maintenance queries. + +Recommended implementation: + +- Tag Studio-originated BFF requests server-side, using request context, `customHeaders`, `customPayload`, or local executor context, and drop them before they enter the Query Insights source. +- Filter known metadata queries at the source, before aggregation, so they do not affect counters or retention. +- Keep an explicit denylist for exact normalized SQL and a small pattern denylist for metadata families that vary by database version. + +Sanitization requirements: + +- Never include raw parameter values, secrets, user input literals, tenant identifiers, connection strings, auth tokens, emails, API keys, or free-form payload data in `query` or `prismaQueryInfo.payload`. +- Prefer parameterized SQL with placeholders (`$1`, `?`, `:param`) or redacted literals. +- Strip or sanitize SQL comments. Comments often carry application context and may contain sensitive values. +- `prismaQueryInfo.payload` should describe query shape, not values. Preserve structural fields such as `select`, `include`, `where` keys, `orderBy`, `take`, and `skip`; redact parameter values recursively. + +## Writes, Transactions, And Multi-Statement Flows + +- Prefer recording each SQL statement separately when the source can observe statement-level execution. +- For a single statement, `duration` SHOULD be the statement's average wall-clock execution duration. +- For `SELECT`, `rowsReturned` SHOULD be the number of rows returned to the caller. +- For `INSERT`, `UPDATE`, and `DELETE` with `RETURNING`, `rowsReturned` SHOULD be the returned row count. +- For `INSERT`, `UPDATE`, and `DELETE` without returned rows, `rowsReturned` SHOULD be `0` unless the provider intentionally maps an affected-row count into this field. If it does, it MUST do so consistently and document that behavior for its own consumers. +- `reads` SHOULD represent the provider's best read-work estimate. It MAY be rows scanned, logical reads, physical reads, or `0` when unknown; Studio treats it as a relative operational signal. +- If a provider can only observe a transaction or batched request as one unit, it MAY emit one aggregated row. In that case `duration` SHOULD be the transaction/request wall-clock duration, `count` should increment once per observed unit, `rowsReturned` and `reads` should be sums across statements when known, and `tables` should be the union of touched tables. +- Failed statements MAY be omitted. If included, they SHOULD have `rowsReturned: 0`, best-effort `duration`, and should not include error messages with sensitive data in `query`. + +## BFF Bridge Contract + +`createStudioBFFClient({ queryInsights: true, ... })` exposes a `queryInsights` provider that uses the same BFF endpoint as query execution. Without that flag, `bffClient.queryInsights` MUST be undefined so embedders do not accidentally render the Queries view against a BFF that does not implement the optional procedure. + +The bridge request body MUST include: + +- `procedure: "query-insights"` +- `limit` when provided by Studio +- `since` when provided by Studio +- `customPayload` when configured on the BFF client + +The BFF response MUST use the existing tuple envelope: + +- `[null, StudioQueryInsightsSnapshot]` for success +- `[SerializedError]` for failure + +This procedure is optional for BFF implementers. If an embedder's BFF does not implement `query-insights`, the embedder MUST omit `bffClient.queryInsights` from the adapter requirements so Studio hides the view. + +The BFF bridge is snapshot based. Studio does not consume a Prisma Streams `streamUrl` for Query Insights. Embedders that already have SSE, pg_stat_statements, ppg.query_stats, query proxy logs, or control-plane telemetry should adapt that source into `StudioQueryInsightsSnapshot` rows before returning the BFF response. + +The BFF endpoint may delegate `query-insights` to a different local sidecar or service than the one that executes Studio SQL. This is the expected shape for environments where query execution is direct TCP but query telemetry is collected by a runtime sidecar, proxy, or control-plane process. The browser-facing Studio adapter still sees one `queryInsights.getSnapshot()` provider and one tuple response. + +## AI Recommendations + +- Query AI recommendations MUST use the shared `llm({ task: "query-insights", prompt })` hook. +- The Queries view MUST hide AI recommendation UI when `llm` is absent. +- The Queries view MUST NOT introduce a separate AI transport, consent flow, or provider-specific code path. +- Studio MUST NOT expose a Query Insights-specific `analyze()` or `enableAiRecommendations()` contract. Hosts that require consent MUST enforce it inside their shared `llm` implementation. +- Studio MAY automatically enqueue AI analysis when a query group first appears in the selected-window query list. Automatic analysis is best-effort UI enrichment, not part of the embedder contract. +- Automatic analysis MUST run through one serial queue with at most one `llm` request in flight. Studio MUST stop automatic background analysis after five query groups per mounted Queries view so a large snapshot does not fan out into many host AI calls. +- Manual analysis requests from the row action or detail sheet MAY enqueue any query group beyond that automatic cap, but they still MUST use the same serial queue and duplicate-suppression rules. +- Prompt construction MUST use the query row Studio is rendering for the selected time window when available. It MUST NOT fetch row data or ask the provider to run SQL. +- Prompt construction MUST preserve the same terminology as the UI: `rowsReturned` is "rows returned"; `reads` is "read work" and SHOULD be omitted when it is unknown or only duplicates `rowsReturned`. +- AI output MUST be parsed and rendered as advisory text, optional query suggestions, and a severity level (`all-good`, `info`, or `warning`); Studio MUST NOT auto-run AI-suggested SQL. +- The query table MAY show an `Analysis` column when `llm` is present. That column SHOULD show queued/running state, a manual analyze action, and the completed severity icon. It MUST be hidden when `llm` is absent. + +## Live Activity Chart + +- The Queries view MAY derive client-local time-series samples from successive snapshot totals. This does not add any embedder contract beyond the existing snapshot fields. +- The initial snapshot SHOULD seed recent chart points from each row's `lastSeen` timestamp so the chart can show activity that was captured before the user opened the view. +- First-snapshot seed points MUST be visual context only. Studio MUST NOT treat an aggregate row's cumulative `count` as one-second throughput, and seed points MUST NOT be connected into live measured line segments. +- Throughput chart points MUST be calculated from execution-count deltas in one-second buckets at each row's reported `lastSeen` time. The selected-window throughput summary MAY still divide measured executions by measured elapsed snapshot time so the headline represents the average rate over the visible measured duration. +- Average latency MUST be calculated from the duration delta for newly observed executions, not from all historical rows in every snapshot. +- Studio MUST keep at most the largest selectable chart window in memory. The default visible window is 5 minutes, and users may switch between 1 minute, 5 minutes, 15 minutes, and 1 hour. +- The visible chart throughput summary MUST average only measured sample intervals that are present in the selected window, not the whole nominal window when the left side has no measured samples. +- The visible chart latency summary MUST use measured executions when available and MAY fall back to visible context latency samples before any measured execution exists. +- The query list MUST use the same selected chart window as the chart. Its `count`, `rowsReturned`, `reads`, average `duration`, and `lastSeen` values MUST be derived from per-query samples inside that visible window instead of displaying the provider's cumulative row counters. +- First-snapshot query rows MAY be shown as context when their `lastSeen` timestamp is inside the selected window, but they MUST be rendered as one sampled execution with average per-execution counters. They MUST NOT display cumulative provider totals as if all historical executions happened inside the selected window. +- First-snapshot context samples MUST NOT create throughput values. If no measured interval exists, the chart MUST show no live throughput value rather than `0/s`. +- The chart MUST break line segments across long gaps that do not have measured samples rather than interpolating across missing time. Short gaps of 30 seconds or less SHOULD remain connected so normal bursts do not fragment. +- Single-sample chart segments MUST render as point markers so isolated measurements remain visible. +- Hover affordances SHOULD reveal exact point values for throughput and latency without changing the selected query row state. + +## Demo Contract + +The `ppg-dev` demo MAY capture successful BFF query executions into an in-memory query-insights store so the feature can be exercised locally. + +The demo recorder is not a production architecture. Production embedders should inject query events from the runtime that observes their application traffic, query proxy, driver instrumentation, or control-plane telemetry. + +The demo recorder MUST skip metadata, lint, and introspection queries so the Queries view focuses on user-visible database activity. + +## Testing Requirements + +Changes to query insights MUST include tests covering: + +- BFF request and error tuple behavior +- navigation visibility and stale `view=queries` fallback +- rendering provider snapshots in the Queries view +- hiding AI recommendations when no `llm` hook is configured +- chart and table metrics scoped to the selected time range +- first-snapshot context rows that do not create fake throughput +- cumulative counter deltas, counter resets, and stale/equal snapshots +- long chart gaps, short connected gaps, and isolated sample markers +- serial automatic AI analysis with the five-query cap and manual analysis beyond that cap +- AI prompt terminology that calls `rowsReturned` "rows returned" instead of "reads" +- demo aggregation behavior for successful query executions diff --git a/Architecture/sql-ai-generation.md b/Architecture/sql-ai-generation.md index 1432ff76..e4384fa3 100644 --- a/Architecture/sql-ai-generation.md +++ b/Architecture/sql-ai-generation.md @@ -55,6 +55,9 @@ This architecture governs: ## Validation and Retry Contract - Studio MUST parse the AI response before updating editor state. +- When `sqlLint` is supported, Studio MUST validate generated SQL before writing it into the editor. +- If pre-display validation reports an error diagnostic, Studio MUST retry once with a correction prompt that includes the generated SQL and lint diagnostic, and MUST NOT show the invalid SQL or rationale while correction is pending. +- If the corrected SQL still fails pre-display validation, Studio MUST surface an inline generation error and MUST leave the current editor contents unchanged. - JSON-response validation and correction retries SHOULD flow through the shared SQL-view AI JSON utility rather than bespoke per-feature retry loops. - If the initial AI response is not valid JSON or does not satisfy the `{ sql, rationale, shouldGenerateVisualization }` contract, Studio MUST retry once with a correction prompt that includes the invalid raw response. - Provider-level output-limit failures MUST surface as explicit retry issues instead of collapsing into a generic malformed-JSON error. @@ -62,6 +65,13 @@ This architecture governs: - Successful responses MAY surface the rationale inline to explain what was generated. - Successful responses MUST carry the visualization decision forward so the next manual execution of that same AI-generated SQL can auto-generate a chart for graph-worthy results. +## Database Error Correction Contract + +- If the user manually executes SQL that was generated by AI and the database returns an execution error, Studio MUST feed the original natural-language request, the failed SQL, and the database error message back through the same `sql-generation` LLM transport. +- The correction request MUST preserve the original explicit-execution contract: corrected SQL replaces the editor contents, updates the rationale and visualization decision, and focuses the editor, but Studio MUST NOT auto-run the corrected SQL. +- Database-error correction MUST only run for editor contents that still match the pending AI-generated SQL. Manually edited SQL errors MUST stay as normal inline query errors and MUST NOT trigger hidden AI calls. +- If database-error correction fails, Studio MUST keep the database error visible and surface the AI correction failure inline without discarding the user's current SQL. + ## Embedder and Demo Contract - Embedders own the actual provider call through one shared `llm({ task, prompt })` hook. @@ -77,7 +87,11 @@ Changes to AI SQL generation MUST include tests covering: - SQL-view integration showing controls only when configured - local prompt-history persistence plus placeholder-preview navigation with `ArrowUp` / `ArrowDown` - filling the editor without auto-executing the generated query +- validating generated SQL before filling the editor and correcting invalid SQL without exposing the invalid draft +- leaving the editor unchanged when generated SQL still fails validation after correction - focusing the editor and moving the cursor to the end of the generated SQL - preserving the visualization decision until the user manually runs the generated SQL - surfacing the visualization decision from the AI response - inline error rendering for failed AI generation +- feeding execution errors from AI-generated SQL back into the model without auto-running the corrected SQL +- leaving manually written SQL execution errors as normal inline query errors without invoking AI correction diff --git a/Architecture/sql-result-visualization.md b/Architecture/sql-result-visualization.md index a645546d..6c8c6cfd 100644 --- a/Architecture/sql-result-visualization.md +++ b/Architecture/sql-result-visualization.md @@ -2,7 +2,7 @@ This document is normative for AI-assisted SQL result visualization in Studio (`view=sql`). -The implementation MUST reuse the existing SQL-view AI transport, render inside the shared scrollable result grid, and generate Chart.js configs without any external chart wrappers. +The implementation MUST reuse the existing SQL-view AI transport, render inside the shared scrollable result grid, and generate a small Studio-owned Bklit chart config instead of accepting arbitrary chart-library options. ## Scope @@ -10,8 +10,8 @@ This architecture governs: - the optional AI visualization row inside SQL result rendering - prompt construction from the executed query plus full result rows -- response validation for AI-generated Chart.js configs -- Chart.js embedding and lifecycle management +- response validation for AI-generated Bklit chart configs +- Bklit chart composition and lifecycle management - visualization reset behavior across query executions ## Canonical Components @@ -28,7 +28,7 @@ This architecture governs: - The idle visualization affordance MUST render on the SQL result summary row, right-aligned on the same line as the `"row(s) returned in Xms"` text. - The mounted chart surface MUST render above the SQL result column header row but inside the shared scrollable grid container. - The implementation MUST use `DataGrid.getBeforeHeaderRows(...)` for the mounted chart rather than a bespoke parallel scroll region. -- The mounted chart surface MUST render inside a full-width white band that stays pinned to the visible scroll-container width rather than stretching with the total table width. +- The mounted chart surface MUST render inside a full-width background band that stays pinned to the visible scroll-container width rather than stretching with the total table width. - The actual chart body inside that band MUST be centered and width-clamped: - minimum 300px - grow with available visible width @@ -48,18 +48,110 @@ This architecture governs: - the executed SQL text under a stable `SQL:` line - the original AI SQL request when the result came from `Generate SQL with AI` - the full result row set -- The prompt MUST explicitly request a Chart.js config and forbid external libraries. +- The prompt MUST explicitly request a Studio Bklit chart config and forbid external libraries. - AI responses MUST be strict JSON and MUST NOT include markdown fences or commentary. - JSON-response validation and correction retries SHOULD flow through the shared SQL-view AI JSON utility rather than a visualization-specific retry loop. - Provider-level output-limit failures MUST surface as explicit retry issues instead of collapsing into a generic malformed-JSON error. -- The validated response shape MUST contain a Chart.js config object suitable for `new Chart(canvas, config)`. -- The supported chart types MUST be constrained to a known allowlist. +- The validated response shape MUST contain a `config` object with this minimal schema: + - `type`: one of `bar`, `horizontal-bar`, `line`, `pie`, or `doughnut` + - `title`: optional short display title + - `data`: plain JSON objects with primitive field values only + - `xKey` plus `series[]` for `bar`, `horizontal-bar`, and `line` charts + - optional `stacked: true` for `bar` and `horizontal-bar` charts only + - `labelKey` plus `valueKey` for `pie` and `doughnut` charts +- `line` charts MUST use date-like `xKey` values: ISO dates, ISO datetimes, or epoch milliseconds. Generic categorical data should use `bar`. +- `horizontal-bar` charts SHOULD be used for ranked categorical results, top-N lists, and long category labels that would collide on a vertical x-axis. +- Stacked `bar` and `horizontal-bar` charts MUST use one data row per category and separate numeric series fields for each stack segment. Long/tidy SQL result rows MAY be pivoted by the AI visualization response into this Studio-owned chart config. +- The supported chart types MUST be constrained to the known allowlist above. - The implementation MUST retry up to two times when the model returns malformed JSON or an invalid chart config, and each correction prompt MUST include the latest validation error. +## Chart Config Examples + +Vertical or horizontal bar charts use one category key and one or more numeric series keys: + +```json +{ + "config": { + "type": "bar", + "title": "Incidents by severity", + "xKey": "severity", + "series": [{ "key": "count", "label": "Incidents" }], + "data": [ + { "severity": "Low", "count": 12 }, + { "severity": "High", "count": 3 } + ] + } +} +``` + +Stacked bar charts use separate numeric fields for each stack segment, not one long/tidy row per segment: + +```json +{ + "config": { + "type": "horizontal-bar", + "title": "Team skills by organization", + "xKey": "organization", + "stacked": true, + "series": [ + { "key": "typescript", "label": "TypeScript" }, + { "key": "postgres", "label": "Postgres" } + ], + "data": [ + { "organization": "Acme", "typescript": 4, "postgres": 2 }, + { "organization": "Globex", "typescript": 1, "postgres": 5 } + ] + } +} +``` + +Line charts require date-like x-values: + +```json +{ + "config": { + "type": "line", + "title": "Rows created over time", + "xKey": "day", + "series": [{ "key": "created", "label": "Created rows" }], + "data": [ + { "day": "2026-06-01", "created": 18 }, + { "day": "2026-06-02", "created": 27 } + ] + } +} +``` + +Pie-like charts use one label key and one numeric value key: + +```json +{ + "config": { + "type": "doughnut", + "title": "Feature flag states", + "labelKey": "state", + "valueKey": "count", + "data": [ + { "state": "Enabled", "count": 9 }, + { "state": "Disabled", "count": 4 } + ] + } +} +``` + +## Renderer Contract + +- `bar` and `horizontal-bar` render through `BarChart`, `Bar`, `BarXAxis`, and `BarYAxis`. +- `line` renders through `LineChart`, `Line`, and `XAxis`. +- `pie` and `doughnut` render through `PieChart` and `PieSlice`. +- All chart types use the shared Bklit `Grid` and `ChartTooltip` primitives where applicable. +- Series colors default to Studio chart CSS variables (`--chart-1` through `--chart-5`) unless a validated series color is provided. +- The renderer MUST ignore arbitrary chart-library options. All display behavior is derived from the validated Studio config and local component composition. + ## Chart Lifecycle Contract -- Chart rendering MUST use Chart.js directly. -- The mounted chart instance MUST be destroyed on unmount and before replacing it with a new chart. +- Chart rendering MUST use the Bklit ShadCN chart primitives checked into `ui/components/charts`. +- The mounted chart MUST remain a React composition, not a canvas lifecycle object. - Visualization reset MUST happen when a new SQL execution starts, even if the previous result grid is still visible while the next query is in flight. ## Testing Requirements @@ -67,8 +159,9 @@ This architecture governs: Changes to SQL result visualization MUST include tests covering: - prompt construction with full result rows -- Chart.js instantiation for a few supported chart types +- validation of supported and unsupported Bklit chart configs - SQL-view integration showing the summary-row `Visualize data with AI` affordance - automatic chart generation for AI-generated SQL results that request visualization - replacement of the action with a mounted chart - reset behavior when another query execution starts +- validation and rendering support for stacked bar and horizontal-bar chart configs diff --git a/FEATURES.md b/FEATURES.md index 9fe1a3cb..8026ef26 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -136,6 +136,15 @@ The visualizer now runs ELK auto-layout with component-aware spacing so disconne Dragged node positions persist when you leave and return to the same schema view, and a header-level `Reset layout` action re-applies the current ELK baseline when you want to discard manual placement. Users can pan/zoom, inspect key and nullable markers, and jump from a node directly to that table’s data view. +## Query Insights + +Embedders can optionally provide live query snapshots through Studio's BFF bridge, and Studio shows them in a dedicated `Queries` view directly under the schema visualizer. +The view plots live query throughput and average latency from recent snapshot rows and successive snapshot updates, defaulting to the most recent 5 minutes with quick switches for 1 minute, 15 minutes, and 1 hour. The chart summary and query list follow the selected time window, including row execution and rows-returned counters, while historical first-snapshot rows render as latency context points instead of fake throughput spikes or cumulative totals. Live throughput points use one-second buckets at the query's observed time, live lines stay connected across short bursts, long unmeasured gaps break into separate segments, isolated samples render as points, and hovering the plot shows exact readings. +Users can filter by touched table, sort by operational signals, and open a detail sheet for SQL, metrics, query metadata, and optional recommendations. +The table and recommendation text label returned-row volume as `Rows Returned`; optional provider read-work estimates stay separate so rows returned are not described as reads. +When Studio's shared `llm` hook is available, the query table adds an Analysis column that analyzes newly observed query groups in the background, one at a time, and stops automatic work after the first five groups. Rows show a running indicator, a manual Analyze action, and a completed all-good, info, or warning icon; the detail sheet uses the same analysis queue for manual recommendations. Without that hook, the AI analysis UI is hidden. +If an embedder does not provide query insights, Studio hides the `Queries` menu item and stale `view=queries` URLs fall back to the normal default view. + ## Data Grid Browsing Table data is shown in a grid with server-backed pagination, filtered-row counts, loading feedback, and explicit empty states. @@ -211,15 +220,17 @@ Embedders can optionally provide the same async `llm` hook on `Studio`, and the When configured, the SQL toolbar adds an inline prompt plus `Generate SQL` action that writes the generated statement into the editor without running it, then focuses the editor so `Cmd/Ctrl+Enter` or the existing `Run SQL` button can execute it as the next explicit step. The prompt context is built from live introspection metadata, including the concrete database engine, active SQL dialect, and available schema/table/column names, but it excludes row data and query results. AI responses must satisfy a strict JSON contract with generated SQL, a short rationale, and a yes/no visualization decision, and Studio retries once if the model returns malformed JSON. +When SQL lint validation is available, generated SQL is validated before it is shown; invalid generated SQL is sent back to the model with the lint diagnostic so Studio can show a corrected statement instead of exposing a broken draft. Submitted AI requests are also stored locally in the SQL-view TanStack collection, so an empty focused prompt field can browse older requests with `ArrowUp` / `ArrowDown` as placeholder-only previews before committing one back into the input for editing. Provider output-limit failures are surfaced explicitly and can feed into the next JSON-correction prompt instead of showing up as a vague parse failure. The visualization decision from AI generation is also preserved so the later manual run can still auto-chart graph-worthy results. +If AI-generated SQL fails when the user runs it, Studio sends the original request, failed SQL, and database error back to the model, then replaces the editor with a corrected query without auto-running it. ## AI SQL Result Visualization -When SQL query results are visible and `llm` is configured, Studio can also turn the returned rows into an in-grid Chart.js visualization. +When SQL query results are visible and `llm` is configured, Studio can also turn the returned rows into an in-grid Bklit visualization. The visualization uses a minimal summary-row trigger labeled `Visualize data with AI`, right-aligned beside the query result count, and mounts the generated chart above the SQL result headers inside the shared scrollable grid without a regenerate control. -Studio sends the executed SQL, the concrete database engine, and the full result row set to the model, and when the result came from `Generate SQL with AI` it also includes the original natural-language request for extra visualization context. The model is asked for a pure Chart.js JSON config with no external libraries, and Studio mounts the returned chart directly with Chart.js. -Mounted charts sit inside a white in-grid band that stays tied to the visible result viewport instead of the total table width, while the chart itself stays centered and width-clamped between 300px and 1200px so wide result grids do not force giant charts. +Studio sends the executed SQL, the concrete database engine, and the full result row set to the model, and when the result came from `Generate SQL with AI` it also includes the original natural-language request for extra visualization context. The model is asked for a strict Studio-owned Bklit chart config for `bar`, `horizontal-bar`, `line`, `pie`, `doughnut`, or stacked `bar`/`horizontal-bar` charts; arbitrary Chart.js-style options, callbacks, plugins, and non-primitive row values are rejected before rendering. +Mounted charts sit inside an in-grid background band that stays tied to the visible result viewport instead of the total table width, while the chart itself stays centered and width-clamped between 300px and 1200px so wide result grids do not force giant charts. When SQL is generated through AI, the same model call also decides whether the expected result is graph-worthy; if it says yes, Studio auto-generates the chart after the user manually runs that generated SQL instead of waiting for a separate chart button click. If another query starts running, the visualization resets immediately so stale charts do not persist across changing result sets. Visualization generation also retries up to two times on malformed JSON, invalid chart configs, or explicit provider output-limit failures. diff --git a/README.md b/README.md index 7c510e3c..5f1bc115 100644 --- a/README.md +++ b/README.md @@ -60,10 +60,14 @@ import { createStudioBFFClient } from "@prisma/studio-core/data/bff"; import { createPostgresAdapter } from "@prisma/studio-core/data/postgres-core"; import { isStudioLlmResponse } from "@prisma/studio-core/data"; +const bffClient = createStudioBFFClient({ + queryInsights: true, + url: "/api/query", +}); + const adapter = createPostgresAdapter({ - executor: createStudioBFFClient({ - url: "/api/query", - }), + executor: bffClient, + queryInsights: bffClient.queryInsights, }); export function EmbeddedStudio() { @@ -101,17 +105,22 @@ export function EmbeddedStudio() { - `adapter` is required. - `llm` is optional and is the single supported AI transport hook for all Studio AI features. - Studio sends the fully constructed prompt plus a task label, and the host returns either `{ ok: true, text }` or `{ ok: false, code, message }`. -- When `llm` is omitted, Studio hides AI filtering, AI SQL generation, and AI visualization affordances entirely. +- When `llm` is omitted, Studio hides AI filtering, AI SQL generation, AI visualization, and Query Insights recommendation affordances entirely. - There are no per-feature AI integration props to wire separately. +- `queryInsights` is optional. Pass it only when your BFF implements the `query-insights` procedure; otherwise omit it so Studio hides the `Queries` view. - Studio does not render a built-in fullscreen header button. If your host needs fullscreen behavior, render that control at the host container level, as the local demo does. -Studio handles prompt construction, type-aware validation, correction retries, SQL execution retries, and conversion into the normal filter, SQL, and visualization surfaces. The host transport only needs to forward the prepared request to an LLM provider and return the typed result. +Studio handles prompt construction, type-aware validation, pre-display SQL validation for AI-generated SQL, correction retries, database-error correction, and conversion into the normal filter, SQL, and visualization surfaces. The host transport only needs to forward the prepared request to an LLM provider and return the typed result. ## AI Contract ```ts type StudioLlmRequest = { - task: "table-filter" | "sql-generation" | "sql-visualization"; + task: + | "table-filter" + | "sql-generation" + | "sql-visualization" + | "query-insights"; prompt: string; }; @@ -128,7 +137,70 @@ type StudioLlmResponse = }; ``` -Studio treats `output-limit-exceeded` as a first-class retry signal for SQL generation and visualization correction loops. All prompting and retry behavior live in Studio itself, so host implementations should stay transport-only. +Studio treats `output-limit-exceeded` as a first-class retry signal for SQL generation and visualization correction loops. When `sqlLint` is available, Studio validates AI-generated SQL before showing it and feeds lint diagnostics back through the same `sql-generation` transport when correction is needed. If AI-generated SQL still fails after the user manually runs it, Studio sends the failed SQL and database error back through that same transport so the model can propose corrected SQL without auto-running it. All prompting and retry behavior live in Studio itself, so host implementations should stay transport-only. + +### SQL Result Visualization Charts + +SQL result visualization uses the shared `llm` hook with `task: "sql-visualization"`. The host does not need to provide chart components, Chart.js options, callbacks, plugins, or chart-specific APIs. Studio builds the prompt from the executed SQL, database engine, full result rows, and the original AI SQL request when available; the host only forwards that prompt to the model and returns the model text. + +Studio validates the model response as a small Bklit chart config before rendering. The response must be strict JSON in this shape: + +```ts +type SqlResultVisualizationResponse = { + config: + | { + type: "bar" | "horizontal-bar" | "line"; + title?: string; + xKey: string; + series: Array<{ + key: string; + label?: string; + color?: string; + }>; + stacked?: boolean; + data: Array>; + } + | { + type: "pie" | "doughnut"; + title?: string; + labelKey: string; + valueKey: string; + data: Array>; + }; +}; +``` + +Chart rules: + +- `bar` and `horizontal-bar` require `xKey` plus one or more numeric `series` fields. +- `stacked: true` is supported only for `bar` and `horizontal-bar`. +- `horizontal-bar` is preferred for ranked categorical data and long category labels. +- `line` requires date-like `xKey` values: ISO dates, ISO datetimes, or epoch milliseconds. +- `pie` and `doughnut` require `labelKey` and a numeric `valueKey`. +- `data` rows must contain plain JSON primitive values only. + +Example stacked horizontal bar response: + +```json +{ + "config": { + "type": "horizontal-bar", + "title": "Team skills by organization", + "xKey": "organization", + "stacked": true, + "series": [ + { "key": "typescript", "label": "TypeScript" }, + { "key": "postgres", "label": "Postgres" } + ], + "data": [ + { "organization": "Acme", "typescript": 4, "postgres": 2 }, + { "organization": "Globex", "typescript": 1, "postgres": 5 } + ] + } +} +``` + +Invalid JSON, unsupported chart types, non-primitive row values, missing keys, non-numeric series values, and non-date line x-values are rejected and fed back to the model for correction. The normative implementation details live in [`Architecture/sql-result-visualization.md`](Architecture/sql-result-visualization.md). ## Integration Checklist @@ -140,16 +212,87 @@ Studio is an embeddable React surface, not a standalone app shell. A production - back that adapter with an authenticated executor, typically `createStudioBFFClient({ url: "/api/query" })` - expose a JSON BFF endpoint that accepts Studio requests and executes them against the database - pass any tenant or auth context through `customHeaders` and/or `customPayload` -- optionally provide `llm` for AI-assisted filtering, SQL generation, and SQL result visualization +- optionally provide `llm` for AI-assisted filtering, SQL generation, SQL result visualization, and Query Insights recommendations +- optionally provide `queryInsights` on the adapter when your BFF can return live query snapshots - own surrounding product chrome such as routing, auth, tenancy, and fullscreen controls The simplest supported shape is: host React app -> `` -> adapter -> `createStudioBFFClient(...)` -> host BFF route -> database executor. +## Query Insights + +Query Insights is an optional Studio feature for embedders that can observe database traffic outside the normal Studio table and SQL views. When enabled, Studio renders a `Queries` navigation item under the schema visualizer with live query rows, throughput and latency charts, and optional AI recommendations for selected queries. + +To enable it: + +1. Implement the BFF `query-insights` procedure described below. +2. Pass `bffClient.queryInsights` into the adapter as `queryInsights`. +3. Optionally pass the shared `llm` hook if you want AI recommendations in query details. + +```ts +const bffClient = createStudioBFFClient({ + queryInsights: true, + url: "/api/query", +}); + +const adapter = createPostgresAdapter({ + executor: bffClient, + queryInsights: bffClient.queryInsights, +}); +``` + +If your BFF does not implement `query-insights`, leave `queryInsights` false/undefined when creating the BFF client and leave the adapter `queryInsights` capability undefined. Studio will hide the `Queries` menu item and stale `view=queries` URLs will fall back to the normal default view. + +What the `Queries` view renders: + +- a live chart for `Queries/s` and average latency with `1m`, `5m`, `15m`, and `1h` ranges +- a query table with `Latency`, sanitized SQL, `Executions`, `Rows Returned`, `Last Seen`, and optional `Analysis` +- table filtering and sorting by rows returned, latency, execution count, or last-seen time +- a detail sheet with the selected query SQL, touched tables, selected-window metrics, and optional AI recommendations +- a pause/resume control for polling the injected snapshot provider + +The chart and table always use the same selected time range. Studio derives visible `Executions`, `Rows Returned`, latency, last-seen values, and any provider read-work estimate from the samples it can place inside that range; it does not show cumulative provider counters as if they all happened in the visible window. The first snapshot can still show recent rows as context when their `lastSeen` timestamp is inside the selected range, but those context rows do not create live throughput values. + +Consumer migration notes: + +- The Studio route is `#view=queries`. Do not link to `#view=query-insights`. +- `queryInsights` is an adapter capability, not a top-level `` prop. Enable the BFF bridge with `createStudioBFFClient({ queryInsights: true, ... })`, then pass `bffClient.queryInsights` into `createPostgresAdapter`, `createMySQLAdapter`, or `createSQLiteAdapter` alongside the executor. +- The packaged BFF bridge uses snapshot polling with `procedure: "query-insights"`. Studio does not require a Prisma Streams `streamUrl`; hosts with SSE, pg_stat_statements, ppg.query_stats, proxy logs, or control-plane telemetry should adapt that source into a `StudioQueryInsightsSnapshot`. +- AI recommendations use the shared `llm` hook with `task: "query-insights"`. Studio does not expose a separate query-specific `analyze()` or `enableAiRecommendations()` transport. Hosts that need consent should enforce it in the `llm` implementation they pass to Studio. +- Automatic AI analysis runs serially, with at most one `llm` request in flight, and stops after the first five automatically discovered query groups. Users can still manually analyze additional rows from the table or detail sheet. + +Snapshot rows should be aggregated by a stable normalized query identity. Do not send raw parameter values or sensitive payloads; use parameterized SQL or another sanitized query representation. `rowsReturned`, `duration`, `count`, and `lastSeen` are best-effort operational signals for display and sorting, not accounting-grade telemetry. Studio labels `rowsReturned` as `Rows Returned` everywhere in the UI and AI advice. `reads` is optional provider read-work telemetry for sources that can estimate rows scanned, logical reads, physical reads, or a similar work signal; leave it at `0` when unknown. Studio derives visible chart and table metrics from deltas between cumulative snapshots inside the selected time window; it does not display cumulative provider counters as selected-window totals. A first snapshot can render recent rows as context, but live throughput is only available after Studio has two increasing snapshots to compare. + +The lowest-fidelity provider Studio fully supports is a generic SQL snapshot with no Prisma metadata: + +```ts +const snapshot = { + generatedAt: Date.now(), + pollingIntervalMs: 1000, + queries: [ + { + id: hash(normalizeSql(sql)), + query: parameterizedOrRedactedSql, + tables: [], + count: cumulativeExecutionCount, + duration: cumulativeAverageDurationMs, + reads: 0, + rowsReturned: cumulativeRowsReturned, + lastSeen: latestObservedAtMs, + prismaQueryInfo: null, + }, + ], +}; +``` + +For this minimum provider, `tables` can be empty, `reads` and `rowsReturned` can be `0` when unknown, and `prismaQueryInfo` can be `null`. The important requirements are stable `id`, sanitized `query`, cumulative counters for the provider retention window, and accurate `lastSeen`. + +The normative integration rules, including sqlcommenter metadata mapping, privacy requirements, counter semantics, and write/transaction guidance, live in [`Architecture/query-insights.md`](Architecture/query-insights.md). + ## BFF Contract Studio's packaged adapters speak one JSON-over-HTTP contract. The host application is expected to implement a POST endpoint, usually `/api/query`, that accepts `StudioBFFRequest` payloads and returns JSON results with serialized errors. -The current contract includes `procedure: "transaction"` so Studio can commit multiple staged row updates in one database transaction when the backend supports it. +The contract includes `procedure: "transaction"` so Studio can commit multiple staged row updates in one database transaction when the backend supports it. It can also include the optional `procedure: "query-insights"` bridge for embedders that provide live query snapshots. ### Transport Rules @@ -182,6 +325,35 @@ type SqlLintDiagnostic = { source?: string; to: number; }; + +type StudioQueryInsightPrismaQueryInfo = { + action: string; + isRaw: boolean; + model?: string; + payload?: Record | Array>; +}; + +type StudioQueryInsightQuery = { + count: number; + duration: number; + groupKey?: string | null; + id: string; + lastSeen: number; + maxDurationMs?: number | null; + minDurationMs?: number | null; + prismaQueryInfo?: StudioQueryInsightPrismaQueryInfo | null; + query: string; + queryId?: string | null; + reads: number; + rowsReturned: number; + tables: string[]; +}; + +type StudioQueryInsightsSnapshot = { + generatedAt: number; + pollingIntervalMs?: number; + queries: StudioQueryInsightQuery[]; +}; ``` ### Request Shapes @@ -208,6 +380,12 @@ type StudioBFFRequest = sql: string; schemaVersion?: string; customPayload?: Record; + } + | { + procedure: "query-insights"; + limit?: number; + since?: number; + customPayload?: Record; }; ``` @@ -233,6 +411,10 @@ type SqlLintResponse = schemaVersion?: string; }, ]; + +type QueryInsightsResponse = + | [SerializedError, undefined?] + | [null, StudioQueryInsightsSnapshot]; ``` ### Procedure Semantics @@ -241,17 +423,23 @@ type SqlLintResponse = - `sequence`: execute exactly two queries in order. This is used by MySQL write flows that update first and refetch second. - `transaction`: execute an ordered list of queries inside one database transaction. This is the contract addition that enables atomic staged multi-row saves from the table editor. - `sql-lint`: return parse/plan diagnostics for the SQL editor and SQL-backed filter pills. +- `query-insights`: return a live query snapshot for the optional `Queries` view. For `sequence`, the second query should only run if the first one succeeds. For `transaction`, the response result array must stay in the same order as `body.queries`. -`sql-lint` is optional because adapters can fall back to adapter-local `EXPLAIN` strategies. `transaction` is strongly recommended because it gives staged multi-row saves atomic behavior; without it, adapters may fall back to sequential writes. +`sql-lint` is optional because adapters can fall back to adapter-local `EXPLAIN` strategies. `query-insights` is optional because not every embedder can observe application query traffic. `transaction` is strongly recommended because it gives staged multi-row saves atomic behavior; without it, adapters may fall back to sequential writes. ## Example BFF Handler -The demo server in this repo is the reference implementation. A host route can mirror it closely: +The demo server in this repo is the reference implementation. A host route can mirror it closely. The example assumes `executor` is your database executor and `queryInsightsStore` is your optional host telemetry source: ```ts -import { serializeError, type StudioBFFRequest } from "@prisma/studio-core/data/bff"; +import { + serializeError, + type StudioBFFRequest, +} from "@prisma/studio-core/data/bff"; + +const queryInsightsStore = createQueryInsightsStoreFromYourTelemetry(); export async function handleStudioBff(request: Request): Promise { if (request.method !== "POST") { @@ -263,6 +451,23 @@ export async function handleStudioBff(request: Request): Promise { const payload = (await request.json()) as StudioBFFRequest; + if (payload.procedure === "query-insights") { + if (!queryInsightsStore) { + return new Response("Query Insights is not supported", { status: 501 }); + } + + try { + const snapshot = await queryInsightsStore.getSnapshot({ + limit: payload.limit, + since: payload.since, + }); + + return Response.json([null, snapshot]); + } catch (error) { + return Response.json([serializeError(error)]); + } + } + if (payload.procedure === "query") { const [error, result] = await executor.execute(payload.query); return Response.json([error ? serializeError(error) : null, result]); @@ -324,8 +529,42 @@ export async function handleStudioBff(request: Request): Promise { - If your host app changes auth or tenant context at runtime, recreate the BFF client or adapter so new `customHeaders` and `customPayload` are used for later requests. - If you are embedding MySQL Studio, keep `sequence` support enabled because the adapter depends on ordered write-plus-refetch flows. - If you want fully atomic staged table saves, implement `transaction` on the BFF and forward it to a real database transaction on the server. +- If you implement Query Insights, capture query events from application runtime instrumentation, a query proxy, driver hooks, or control-plane telemetry. Do not rely on Studio's own table and SQL requests as the source of production traffic. +- Exclude Studio-generated introspection, table browsing, SQL lint, metadata, health-check, and `query-insights` snapshot requests from production Query Insights before aggregation. - If you omit `llm`, Studio still supports the full manual filtering UI and the standard SQL editor, just without any AI affordances. +### Split Query-Insights Service + +The BFF endpoint that executes Studio SQL can delegate only `query-insights` to a sidecar. This is useful when direct query execution and query telemetry are owned by different local services: + +```ts +if (payload.procedure === "query-insights") { + const queryInsightsUrl = new URL("http://127.0.0.1:5556/snapshot"); + + queryInsightsUrl.searchParams.set("limit", String(payload.limit ?? 500)); + + if (payload.since != null) { + queryInsightsUrl.searchParams.set("since", String(payload.since)); + } + + const response = await fetch(queryInsightsUrl, { + headers: { + authorization: request.headers.get("authorization") ?? "", + }, + }); + + if (!response.ok) { + return Response.json([ + serializeError(new Error("Query Insights sidecar failed")), + ]); + } + + return Response.json([null, await response.json()]); +} +``` + +The sidecar response should already be a `StudioQueryInsightsSnapshot`. Other BFF procedures can continue to use the normal database executor. + ## Telemetry This package includes anonymized telemetry to help us improve Prisma Studio. @@ -349,7 +588,7 @@ pnpm demo:ppg Then open [http://localhost:4310](http://localhost:4310). To enable the demo's AI flows, copy `.env.example` to `.env` and set `ANTHROPIC_API_KEY`. -The demo reads that key server-side and calls Anthropic Haiku 4.5 directly over HTTP through one shared `llm` hook used by table filtering, SQL generation, and SQL result visualization. Set `STUDIO_DEMO_AI_ENABLED=false` to hide all AI affordances without removing the key. `STUDIO_DEMO_AI_FILTERING_ENABLED` is still accepted as a legacy alias. `.env` and `.env.local` are gitignored. +The demo reads that key server-side and calls Anthropic Haiku 4.5 directly over HTTP through one shared `llm` hook used by table filtering, SQL generation, SQL result visualization, and Query Insights recommendations. Set `STUDIO_DEMO_AI_ENABLED=false` to hide all AI affordances without removing the key. `STUDIO_DEMO_AI_FILTERING_ENABLED` is still accepted as a legacy alias. `.env` and `.env.local` are gitignored. The demo: @@ -411,9 +650,8 @@ Deploy that artifact with: ```sh bunx @prisma/compute-cli deploy --skip-build \ --path deploy \ - --entrypoint bundle/server.bundle.js \ + --entrypoint bundle/compute-entrypoint.js \ --http-port 8080 \ - --env STUDIO_DEMO_PORT=8080 \ --service ``` diff --git a/components.json b/components.json index 3a0554e1..6653467c 100644 --- a/components.json +++ b/components.json @@ -10,6 +10,7 @@ "cssVariables": true, "prefix": "ps-" }, + "iconLibrary": "lucide", "aliases": { "components": "@/ui/components", "utils": "@/ui/lib/utils", @@ -17,5 +18,7 @@ "lib": "@/ui/lib", "hooks": "@/ui/hooks" }, - "iconLibrary": "lucide" + "registries": { + "@bklit": "https://ui.bklit.com/r/{name}.json" + } } diff --git a/data/adapter.ts b/data/adapter.ts index 10e1306a..c1491a21 100644 --- a/data/adapter.ts +++ b/data/adapter.ts @@ -1,10 +1,12 @@ import type { Executor } from "./executor"; import type { Query } from "./query"; +import type { StudioQueryInsights } from "./query-insights"; import type { BigIntString, Either, NumericString } from "./type-utils"; export interface AdapterRequirements { executor: Executor; noParameters?: boolean; + queryInsights?: StudioQueryInsights; } export interface AdapterCapabilities { @@ -42,6 +44,14 @@ export interface Adapter { */ readonly capabilities?: Partial; + /** + * Optional live query-insights provider. + * + * Embedders that can observe SQL execution outside Studio should inject + * snapshots here, usually through the Studio BFF bridge. + */ + readonly queryInsights?: StudioQueryInsights; + /** * Introspects the database and returns structured information about the schemas, tables, etc. * diff --git a/data/bff/bff-client.test.ts b/data/bff/bff-client.test.ts index 39853b34..49396454 100644 --- a/data/bff/bff-client.test.ts +++ b/data/bff/bff-client.test.ts @@ -655,5 +655,87 @@ describe("bff/bff-client", () => { expect(result).toBeUndefined(); }); }); + + describe("queryInsights", () => { + const snapshot = { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 2, + duration: 12, + id: "select-users", + lastSeen: 1_779_963_199_000, + query: "select * from users", + reads: 4, + rowsReturned: 4, + tables: ["users"], + }, + ], + }; + + let client: StudioBFFClient; + const fetchFn = vi.fn((...args: Parameters) => + fetch(...args), + ); + + beforeEach(() => { + client = createStudioBFFClient({ + customHeaders, + customPayload, + fetch: fetchFn, + queryInsights: true, + url, + }); + }); + + it("omits queryInsights unless the optional BFF capability is enabled", () => { + expect( + createStudioBFFClient({ + fetch: fetchFn, + url, + }).queryInsights, + ).toBeUndefined(); + }); + + it("requests query-insights snapshots through the same BFF channel", async () => { + fetchFn.mockResolvedValueOnce( + new Response(JSON.stringify([null, snapshot])), + ); + + const response = await client.queryInsights!.getSnapshot({ + limit: 50, + since: 1_779_963_000_000, + }); + + expect(fetchFn).toHaveBeenCalledWith(url, { + body: expect.toSatisfy((value) => { + expect(JSON.parse(value as string)).toStrictEqual({ + customPayload, + limit: 50, + procedure: "query-insights", + since: 1_779_963_000_000, + }); + + return true; + }) as string, + headers: expect.objectContaining(customHeaders) as object, + method: "POST", + }); + expect(response).toStrictEqual([null, snapshot]); + }); + + it("returns an error tuple when the query-insights request fails", async () => { + fetchFn.mockResolvedValueOnce( + new Response("query insights unavailable", { status: 501 }), + ); + + const response = await client.queryInsights!.getSnapshot({}); + + expect(response).toStrictEqual([ + new Error("query insights unavailable"), + ]); + }); + }); }); }); diff --git a/data/bff/bff-client.ts b/data/bff/bff-client.ts index 15895008..f0d15198 100644 --- a/data/bff/bff-client.ts +++ b/data/bff/bff-client.ts @@ -1,9 +1,16 @@ import type { AdapterSqlLintDiagnostic } from "../adapter"; import type { ExecuteOptions, SequenceExecutor } from "../executor"; import type { Query, QueryResult } from "../query"; +import type { + StudioQueryInsights, + StudioQueryInsightsSnapshot, + StudioQueryInsightsSnapshotRequest, +} from "../query-insights"; import type { Either } from "../type-utils"; -type FetchLike = (...args: Parameters) => ReturnType; +type FetchLike = ( + ...args: Parameters +) => ReturnType; const bffRequestDurationByAbortSignal = new WeakMap(); @@ -183,6 +190,15 @@ export interface StudioBFFClientProps { */ fetch?: FetchLike; + /** + * Enables the optional Query Insights BFF procedure. + * + * Leave this false/undefined when the BFF does not implement + * `procedure: "query-insights"` so embedders do not accidentally expose the + * Studio Queries view. + */ + queryInsights?: boolean; + /** * Function used to deserialize the results of queries. * @@ -243,13 +259,23 @@ export interface StudioBFFClient extends SequenceExecutor { details: StudioBFFSqlLintDetails, options?: ExecuteOptions, ): Promise>; + + /** + * Optional Studio query-insights bridge. + * + * Implementers decide whether to pass this provider into their Studio + * adapter. If their BFF does not support the procedure, omit it from the + * adapter so Studio hides the Queries view. + */ + queryInsights?: StudioQueryInsights; } export type StudioBFFRequest = | StudioBFFQueryRequest | StudioBFFSequenceRequest | StudioBFFTransactionRequest - | StudioBFFSqlLintRequest; + | StudioBFFSqlLintRequest + | StudioBFFQueryInsightsRequest; export interface StudioBFFQueryRequest { customPayload?: Record; @@ -286,6 +312,11 @@ export interface StudioBFFSqlLintRequest { sql: string; } +export interface StudioBFFQueryInsightsRequest extends StudioQueryInsightsSnapshotRequest { + customPayload?: Record; + procedure: "query-insights"; +} + /** * Creates a Studio BFF client. BFF stands for "Backend For Frontend" btw. */ @@ -295,7 +326,7 @@ export function createStudioBFFClient( const { customHeaders, customPayload, resultDeserializerFn, url } = props; const fetchFn = props.fetch || fetch; - return { + const client: StudioBFFClient = { async execute(query, options) { try { const requestStartedAt = performance.now(); @@ -468,8 +499,8 @@ export function createStudioBFFClient( return [deserializeError(error)]; } - const deserializedResults = (results ?? []).map((result) => - (resultDeserializerFn?.(result) || result) as never, + const deserializedResults = (results ?? []).map( + (result) => (resultDeserializerFn?.(result) || result) as never, ); return [null, deserializedResults]; @@ -532,6 +563,57 @@ export function createStudioBFFClient( } }, }; + + if (props.queryInsights === true) { + client.queryInsights = { + async getSnapshot(request, options) { + try { + const response = await fetchFn(url, { + body: JSON.stringify({ + customPayload, + limit: request.limit, + procedure: "query-insights", + since: request.since, + } satisfies StudioBFFQueryInsightsRequest), + headers: { + Accept: "application/json", + "Content-Type": "application/json", + ...customHeaders, + }, + method: "POST", + signal: options?.abortSignal, + }); + + if (!response.ok) { + let errorText: string; + + try { + errorText = await response.text(); + } catch { + errorText = "unknown error"; + } + + return [new Error(errorText)]; + } + + const [error, snapshot] = (await response.json()) as [ + SerializedError, + StudioQueryInsightsSnapshot, + ]; + + if (error) { + return [deserializeError(error)]; + } + + return [null, snapshot]; + } catch (error: unknown) { + return [error as Error]; + } + }, + }; + } + + return client; } export interface SerializedError { diff --git a/data/index.ts b/data/index.ts index d8aeeac5..02c1ca74 100644 --- a/data/index.ts +++ b/data/index.ts @@ -10,6 +10,7 @@ export { } from "./llm"; export type * from "./llm"; export { applyInferredRowFilters, type Query, type QueryResult } from "./query"; +export type * from "./query-insights"; export * from "./sql-editor-schema"; export * from "./sql-statements"; export type * from "./type-utils"; diff --git a/data/llm.ts b/data/llm.ts index 5ea8ccb9..4af6d528 100644 --- a/data/llm.ts +++ b/data/llm.ts @@ -2,6 +2,7 @@ const LEGACY_OUTPUT_LIMIT_SIGNAL_PREFIX = "__PRISMA_STUDIO_AI_OUTPUT_LIMIT__:"; export const STUDIO_LLM_TASKS = [ "table-filter", + "query-insights", "sql-generation", "sql-visualization", ] as const; @@ -77,7 +78,7 @@ export function isStudioLlmResponse( response.ok === false && typeof response.message === "string" && typeof response.code === "string" && - STUDIO_LLM_ERROR_CODES.includes(response.code as StudioLlmErrorCode) + STUDIO_LLM_ERROR_CODES.includes(response.code) ); } diff --git a/data/mysql-core/adapter.ts b/data/mysql-core/adapter.ts index 2cdcf4b0..1f2906ec 100644 --- a/data/mysql-core/adapter.ts +++ b/data/mysql-core/adapter.ts @@ -1,6 +1,5 @@ import { type Adapter, - type AdapterUpdateDetails, type AdapterDeleteResult, type AdapterError, type AdapterInsertResult, @@ -10,6 +9,7 @@ import { type AdapterRequirements, type AdapterSqlLintResult, type AdapterSqlSchemaResult, + type AdapterUpdateDetails, type AdapterUpdateManyResult, type AdapterUpdateResult, type Column, @@ -54,7 +54,7 @@ export type MySQLAdapterRequirements = Omit & { export function createMySQLAdapter( requirements: MySQLAdapterRequirements, ): Adapter { - const { executor, ...otherRequirements } = requirements; + const { executor, queryInsights, ...otherRequirements } = requirements; const fullTableSearchState = createFullTableSearchExecutionState(); let canUseExecutorLintTransport = typeof executor.lintSql === "function"; const createMySQLAdapterError = ( @@ -152,8 +152,8 @@ export function createMySQLAdapter( options: Parameters[0], ): Promise> { try { - const tablesQuery = getTablesQuery(requirements); - const timezoneQuery = getTimezoneQuery(requirements); + const tablesQuery = getTablesQuery(otherRequirements); + const timezoneQuery = getTimezoneQuery(otherRequirements); const [[tablesError, tables], [timezoneError, timezones]] = await Promise.all([ @@ -182,6 +182,7 @@ export function createMySQLAdapter( } return { + queryInsights, capabilities: { fullTableSearch: true, sqlDialect: "mysql", diff --git a/data/mysql-core/sql-editor.adapter.test.ts b/data/mysql-core/sql-editor.adapter.test.ts index 7ffb0be6..3caabcb4 100644 --- a/data/mysql-core/sql-editor.adapter.test.ts +++ b/data/mysql-core/sql-editor.adapter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AdapterSqlLintResult } from "../adapter"; import type { SequenceExecutor } from "../executor"; +import type { StudioQueryInsights } from "../query-insights"; import { createMySQLAdapter } from "./adapter"; function createSequenceExecutor(args?: { @@ -28,6 +29,18 @@ describe("mysql-core/adapter sql-editor support", () => { expect(adapter.capabilities?.sqlEditorLint).toBe(true); }); + it("preserves the optional query insights provider", () => { + const queryInsights: StudioQueryInsights = { + getSnapshot: vi.fn(), + }; + const adapter = createMySQLAdapter({ + executor: createSequenceExecutor(), + queryInsights, + }); + + expect(adapter.queryInsights).toBe(queryInsights); + }); + it("delegates sql lint to executor lintSql when available", async () => { const lintResult: AdapterSqlLintResult = { diagnostics: [ diff --git a/data/postgres-core/adapter.ts b/data/postgres-core/adapter.ts index a962baaf..2f84b02b 100644 --- a/data/postgres-core/adapter.ts +++ b/data/postgres-core/adapter.ts @@ -1,6 +1,5 @@ import { type Adapter, - type AdapterUpdateDetails, type AdapterDeleteResult, type AdapterError, type AdapterInsertResult, @@ -12,6 +11,7 @@ import { type AdapterSqlLintDetails, type AdapterSqlLintResult, type AdapterSqlSchemaResult, + type AdapterUpdateDetails, type AdapterUpdateManyResult, type AdapterUpdateResult, type Column, @@ -49,7 +49,7 @@ export type PostgresAdapterRequirements = AdapterRequirements; export function createPostgresAdapter( requirements: PostgresAdapterRequirements, ): Adapter { - const { executor, ...otherRequirements } = requirements; + const { executor, queryInsights, ...otherRequirements } = requirements; const fullTableSearchState = createFullTableSearchExecutionState(); let canUseExecutorLintTransport = typeof executor.lintSql === "function"; const createPostgresAdapterError = ( @@ -155,6 +155,7 @@ export function createPostgresAdapter( return { defaultSchema: "public", + queryInsights, capabilities: { fullTableSearch: true, sqlDialect: "postgresql", diff --git a/data/postgres-core/sql-editor.adapter.test.ts b/data/postgres-core/sql-editor.adapter.test.ts index 06cdcc3f..26603d03 100644 --- a/data/postgres-core/sql-editor.adapter.test.ts +++ b/data/postgres-core/sql-editor.adapter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AdapterSqlLintResult } from "../adapter"; import type { Executor } from "../executor"; +import type { StudioQueryInsights } from "../query-insights"; import { createPostgresAdapter } from "./adapter"; import { mockTablesQuery, mockTimezoneQuery } from "./introspection"; @@ -59,6 +60,18 @@ describe("postgres-core/adapter sql-editor support", () => { expect(adapter.capabilities?.sqlEditorLint).toBe(true); }); + it("preserves the optional query insights provider", () => { + const queryInsights: StudioQueryInsights = { + getSnapshot: vi.fn(), + }; + const adapter = createPostgresAdapter({ + executor: createExecutor(), + queryInsights, + }); + + expect(adapter.queryInsights).toBe(queryInsights); + }); + it("delegates sql lint to executor lintSql when available", async () => { const lintResult: AdapterSqlLintResult = { diagnostics: [ diff --git a/data/query-insights.ts b/data/query-insights.ts new file mode 100644 index 00000000..6b150219 --- /dev/null +++ b/data/query-insights.ts @@ -0,0 +1,43 @@ +import type { ExecuteOptions } from "./executor"; +import type { Either } from "./type-utils"; + +export interface StudioQueryInsightPrismaQueryInfo { + action: string; + isRaw: boolean; + model?: string; + payload?: Record | Array>; +} + +export interface StudioQueryInsightQuery { + count: number; + duration: number; + groupKey?: string | null; + id: string; + lastSeen: number; + maxDurationMs?: number | null; + minDurationMs?: number | null; + prismaQueryInfo?: StudioQueryInsightPrismaQueryInfo | null; + query: string; + queryId?: string | null; + reads: number; + rowsReturned: number; + tables: string[]; +} + +export interface StudioQueryInsightsSnapshot { + generatedAt: number; + pollingIntervalMs?: number; + queries: StudioQueryInsightQuery[]; +} + +export interface StudioQueryInsightsSnapshotRequest { + limit?: number; + since?: number; +} + +export interface StudioQueryInsights { + getSnapshot( + request: StudioQueryInsightsSnapshotRequest, + options?: ExecuteOptions, + ): Promise>; +} diff --git a/data/sqlite-core/adapter.ts b/data/sqlite-core/adapter.ts index 88d53db9..1574fb22 100644 --- a/data/sqlite-core/adapter.ts +++ b/data/sqlite-core/adapter.ts @@ -1,6 +1,5 @@ import { type Adapter, - type AdapterUpdateDetails, type AdapterDeleteResult, type AdapterError, type AdapterInsertResult, @@ -10,6 +9,7 @@ import { type AdapterRequirements, type AdapterSqlLintResult, type AdapterSqlSchemaResult, + type AdapterUpdateDetails, type AdapterUpdateManyResult, type AdapterUpdateResult, createAdapterError, @@ -56,7 +56,7 @@ const filterOperators = [ export function createSQLiteAdapter( requirements: SQLIteAdapterRequirements, ): Adapter { - const { executor, ...otherRequirements } = requirements; + const { executor, queryInsights, ...otherRequirements } = requirements; const fullTableSearchState = createFullTableSearchExecutionState(); let canUseExecutorLintTransport = typeof executor.lintSql === "function"; const createSQLiteAdapterError = ( @@ -153,6 +153,7 @@ export function createSQLiteAdapter( return { defaultSchema: schema, + queryInsights, capabilities: { fullTableSearch: true, sqlDialect: "sqlite", diff --git a/data/sqlite-core/sql-editor.adapter.test.ts b/data/sqlite-core/sql-editor.adapter.test.ts index f8b43d4b..a1104cb2 100644 --- a/data/sqlite-core/sql-editor.adapter.test.ts +++ b/data/sqlite-core/sql-editor.adapter.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AdapterSqlLintResult } from "../adapter"; import type { Executor } from "../executor"; +import type { StudioQueryInsights } from "../query-insights"; import { createSQLiteAdapter } from "./adapter"; function createExecutor(args?: { @@ -27,6 +28,18 @@ describe("sqlite-core/adapter sql-editor support", () => { expect(adapter.capabilities?.sqlEditorLint).toBe(true); }); + it("preserves the optional query insights provider", () => { + const queryInsights: StudioQueryInsights = { + getSnapshot: vi.fn(), + }; + const adapter = createSQLiteAdapter({ + executor: createExecutor(), + queryInsights, + }); + + expect(adapter.queryInsights).toBe(queryInsights); + }); + it("delegates sql lint to executor lintSql when available", async () => { const lintResult: AdapterSqlLintResult = { diagnostics: [ diff --git a/demo/ppg-dev/build-compute.test.ts b/demo/ppg-dev/build-compute.test.ts index 226cc512..86c0035c 100644 --- a/demo/ppg-dev/build-compute.test.ts +++ b/demo/ppg-dev/build-compute.test.ts @@ -177,6 +177,7 @@ describe("build-compute", () => { expect(rootEntries.some((entry) => entry.endsWith(".data"))).toBe(false); expect(bundleEntries).toContain("server.bundle.js"); + expect(bundleEntries).toContain("compute-entrypoint.js"); expect(bundleEntries).toContain("initdb.wasm"); expect(bundleEntries).toContain("pglite.data"); expect(bundleEntries).toContain("pglite.wasm"); @@ -213,13 +214,23 @@ describe("build-compute", () => { expect(serverBundle).not.toContain( "sourceMappingURL=data:application/json;base64", ); + const computeEntrypoint = await readFile( + join(outputDir, "bundle", "compute-entrypoint.js"), + "utf8", + ); + expect(computeEntrypoint).toContain( + 'process.env.STUDIO_DEMO_PORT ??= "8080";', + ); + expect(computeEntrypoint).toContain( + 'await import("./server.bundle.js");', + ); if (!supportsBundledPrismaDevBoot(bunVersion)) { return; } const port = await getAvailablePort(); - const serverProcess = spawn("bun", ["./bundle/server.bundle.js"], { + const serverProcess = spawn("bun", ["./bundle/compute-entrypoint.js"], { cwd: outputDir, env: { ...process.env, diff --git a/demo/ppg-dev/build-compute.ts b/demo/ppg-dev/build-compute.ts index 837591cf..d7c70447 100644 --- a/demo/ppg-dev/build-compute.ts +++ b/demo/ppg-dev/build-compute.ts @@ -16,13 +16,21 @@ * Deploy: * * bunx @prisma/compute-cli deploy --skip-build \ - * --path --entrypoint bundle/server.bundle.js \ - * --http-port 8080 --env STUDIO_DEMO_PORT=8080 \ + * --path --entrypoint bundle/compute-entrypoint.js \ + * --http-port 8080 \ * --service */ import { existsSync } from "node:fs"; -import { cp, mkdir, readdir, rename, rm, stat } from "node:fs/promises"; +import { + cp, + mkdir, + readdir, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { createRequire } from "node:module"; import { basename, dirname, extname, join, resolve } from "node:path"; import { pathToFileURL } from "node:url"; @@ -167,6 +175,7 @@ console.log( `[build] Copied Prisma Dev runtime assets: ${copiedRuntimeAssets.length}`, ); +await writeComputeEntrypoint(bundleDir); await bundlePrismaStreamsTouchAssets(outDir); console.log("[build] Bundled Prisma Streams worker assets."); @@ -275,3 +284,14 @@ async function bundlePrismaStreamsTouchAssets(outDir: string): Promise { recursive: true, }); } + +async function writeComputeEntrypoint(bundleDir: string): Promise { + await writeFile( + join(bundleDir, "compute-entrypoint.js"), + [ + 'process.env.STUDIO_DEMO_PORT ??= "8080";', + 'await import("./server.bundle.js");', + "", + ].join("\n"), + ); +} diff --git a/demo/ppg-dev/client.tsx b/demo/ppg-dev/client.tsx index 0d3955d6..7fa0c608 100644 --- a/demo/ppg-dev/client.tsx +++ b/demo/ppg-dev/client.tsx @@ -35,14 +35,21 @@ async function bootstrap(): Promise { } const config = (await configResponse.json()) as DemoConfig; + let adapter: Adapter; - const adapter: Adapter = config.database.enabled - ? createPostgresAdapter({ - executor: createStudioBFFClient({ - url: "/api/query", - }), - }) - : createNoDatabaseAdapter(); + if (config.database.enabled) { + const bffClient = createStudioBFFClient({ + queryInsights: config.queries.enabled === true, + url: "/api/query", + }); + + adapter = createPostgresAdapter({ + executor: bffClient, + queryInsights: bffClient.queryInsights, + }); + } else { + adapter = createNoDatabaseAdapter(); + } root.render( { database: { enabled: true, }, + queries: { + enabled: true, + }, seededAt: "2026-03-09T10:00:00.000Z", streams: { url: "/api/streams", @@ -45,12 +48,27 @@ describe("buildDemoConfig", () => { database: { enabled: false, }, + queries: { + enabled: false, + }, streams: { url: "/api/streams", }, }); expect("seededAt" in config).toBe(false); }); + + it("allows query insights to be disabled independently from the database", () => { + const config = buildDemoConfig({ + aiEnabled: false, + bootId: "boot-789", + databaseEnabled: true, + queryInsightsEnabled: false, + }); + + expect(config.queries.enabled).toBe(false); + expect(config.database.enabled).toBe(true); + }); }); describe("resolveDemoAiEnabled", () => { diff --git a/demo/ppg-dev/config.ts b/demo/ppg-dev/config.ts index c268972a..f7a32298 100644 --- a/demo/ppg-dev/config.ts +++ b/demo/ppg-dev/config.ts @@ -6,6 +6,9 @@ export interface DemoConfig { database: { enabled: boolean; }; + queries: { + enabled: boolean; + }; seededAt?: string; streams?: { url: string; @@ -50,10 +53,18 @@ export function buildDemoConfig(args: { aiEnabled: boolean; bootId: string; databaseEnabled: boolean; + queryInsightsEnabled?: boolean; seededAt?: string | null; streamsUrl?: string; }): DemoConfig { - const { aiEnabled, bootId, databaseEnabled, seededAt, streamsUrl } = args; + const { + aiEnabled, + bootId, + databaseEnabled, + queryInsightsEnabled = databaseEnabled, + seededAt, + streamsUrl, + } = args; return { ai: { @@ -63,6 +74,9 @@ export function buildDemoConfig(args: { database: { enabled: databaseEnabled, }, + queries: { + enabled: queryInsightsEnabled, + }, ...(seededAt ? { seededAt, diff --git a/demo/ppg-dev/query-insights.test.ts b/demo/ppg-dev/query-insights.test.ts new file mode 100644 index 00000000..bff909e6 --- /dev/null +++ b/demo/ppg-dev/query-insights.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; + +import { asQuery } from "../../data/query"; +import { createDemoQueryInsightsStore } from "./query-insights"; + +describe("createDemoQueryInsightsStore", () => { + it("aggregates successful BFF query executions into query-insights snapshots", () => { + let now = 1_779_963_000_000; + const store = createDemoQueryInsightsStore({ + now: () => now, + }); + + store.record({ + durationMs: 12, + query: asQuery("select * from public.users where id = $1"), + result: [{ id: "user-1" }], + }); + now += 100; + store.record({ + durationMs: 24, + query: asQuery("select * from public.users where id = $1"), + result: [{ id: "user-2" }, { id: "user-3" }], + }); + + const snapshot = store.getSnapshot({ limit: 10 }); + + expect(snapshot.queries).toEqual([ + expect.objectContaining({ + count: 2, + duration: 18, + lastSeen: 1_779_963_000_100, + query: "select * from public.users where id = $1", + reads: 0, + rowsReturned: 3, + tables: ["public.users"], + }), + ]); + }); + + it("skips Studio metadata and lint queries so the demo surface stays focused", () => { + const store = createDemoQueryInsightsStore({ + now: () => 1_779_963_000_000, + }); + + store.record({ + durationMs: 3, + query: asQuery("select * from information_schema.tables"), + result: [{ table_name: "users" }], + }); + store.record({ + durationMs: 3, + query: asQuery("EXPLAIN select * from users"), + result: [], + }); + store.record({ + durationMs: 3, + query: asQuery("select current_setting('timezone') as \"timezone\""), + result: [{ timezone: "UTC" }], + }); + + expect(store.getSnapshot({}).queries).toEqual([]); + }); + + it("extracts real tables from Studio grid queries without including CTE names", () => { + const store = createDemoQueryInsightsStore({ + now: () => 1_779_963_000_000, + }); + + store.record({ + durationMs: 3, + query: asQuery( + 'with "__ps_agg__" as (select count(*) from "public"."organizations") select "__ps_agg__".count, "id" from "public"."organizations" inner join "__ps_agg__" on true limit $1', + ), + result: [{ id: "org_acme" }], + }); + + expect(store.getSnapshot({}).queries[0]).toEqual( + expect.objectContaining({ + tables: ["public.organizations"], + }), + ); + }); +}); diff --git a/demo/ppg-dev/query-insights.ts b/demo/ppg-dev/query-insights.ts new file mode 100644 index 00000000..86ee397d --- /dev/null +++ b/demo/ppg-dev/query-insights.ts @@ -0,0 +1,180 @@ +import type { Query, QueryResult } from "../../data"; +import type { + StudioQueryInsightQuery, + StudioQueryInsightsSnapshot, + StudioQueryInsightsSnapshotRequest, +} from "../../data/query-insights"; + +const DEFAULT_DEMO_QUERY_INSIGHTS_LIMIT = 500; +const CTE_PATTERN = + /(?:\bwith|,)\s+((?:"[^"]+"|[a-z_][a-z0-9_$]*))\s+as\s*\(/gi; +const TABLE_PATTERN = + /\b(?:from|join|into|update)\s+((?:"[^"]+"|[a-z_][a-z0-9_$]*)(?:\s*\.\s*(?:"[^"]+"|[a-z_][a-z0-9_$]*))?)/gi; +const TIMEZONE_METADATA_PATTERN = + /current_setting\s*\(\s*['"]time_?zone['"]\s*\)/i; + +export interface DemoQueryInsightsStore { + getSnapshot( + request: StudioQueryInsightsSnapshotRequest, + ): StudioQueryInsightsSnapshot; + record(args: { + durationMs: number; + query: Query; + result: QueryResult>; + }): void; +} + +export function createDemoQueryInsightsStore(args?: { + now?: () => number; +}): DemoQueryInsightsStore { + const now = args?.now ?? (() => Date.now()); + const queriesById = new Map(); + + return { + getSnapshot(request) { + const limit = normalizeLimit(request.limit); + const queries = [...queriesById.values()] + .filter((query) => { + return request.since == null || query.lastSeen >= request.since; + }) + .sort((left, right) => right.lastSeen - left.lastSeen) + .slice(0, limit); + + return { + generatedAt: now(), + pollingIntervalMs: 1000, + queries, + }; + }, + + record({ durationMs, query, result }) { + if (!shouldRecordQuery(query.sql)) { + return; + } + + const id = normalizeQueryId(query.sql); + const lastSeen = now(); + const rowCount = Array.isArray(result) ? result.length : 0; + // The demo can observe returned rows, not database read work. + const reads = 0; + const existing = queriesById.get(id); + + if (!existing) { + queriesById.set(id, { + count: 1, + duration: durationMs, + id, + lastSeen, + maxDurationMs: durationMs, + minDurationMs: durationMs, + query: query.sql, + reads, + rowsReturned: rowCount, + tables: extractTablesFromSql(query.sql), + }); + return; + } + + const nextCount = existing.count + 1; + + queriesById.set(id, { + ...existing, + count: nextCount, + duration: (existing.duration * existing.count + durationMs) / nextCount, + lastSeen, + maxDurationMs: Math.max(existing.maxDurationMs ?? 0, durationMs), + minDurationMs: Math.min( + existing.minDurationMs ?? durationMs, + durationMs, + ), + reads: existing.reads + reads, + rowsReturned: existing.rowsReturned + rowCount, + tables: mergeTables(existing.tables, extractTablesFromSql(query.sql)), + }); + }, + }; +} + +function normalizeLimit(limit: number | undefined): number { + if ( + typeof limit === "number" && + Number.isInteger(limit) && + Number.isSafeInteger(limit) && + limit > 0 + ) { + return Math.min(limit, DEFAULT_DEMO_QUERY_INSIGHTS_LIMIT); + } + + return DEFAULT_DEMO_QUERY_INSIGHTS_LIMIT; +} + +function shouldRecordQuery(sql: string): boolean { + const normalizedSql = sql.trim().toLowerCase(); + + if (normalizedSql.length === 0) { + return false; + } + + return ( + !normalizedSql.startsWith("explain ") && + !normalizedSql.includes("information_schema.") && + !normalizedSql.includes('"information_schema"') && + !normalizedSql.includes("pg_catalog.") && + !normalizedSql.includes('"pg_catalog"') && + !TIMEZONE_METADATA_PATTERN.test(normalizedSql) + ); +} + +function normalizeQueryId(sql: string): string { + return sql.replace(/\s+/g, " ").trim(); +} + +function extractTablesFromSql(sql: string): string[] { + const tables = new Set(); + const cteNames = extractCteNamesFromSql(sql); + + for (const match of sql.matchAll(TABLE_PATTERN)) { + const rawIdentifier = match[1]; + + if (!rawIdentifier) { + continue; + } + + const identifier = normalizeSqlIdentifier(rawIdentifier); + + if (!cteNames.has(identifier)) { + tables.add(identifier); + } + } + + return [...tables].filter((table) => table.length > 0).sort(); +} + +function extractCteNamesFromSql(sql: string): Set { + if (!/\bwith\b/i.test(sql)) { + return new Set(); + } + + const cteNames = new Set(); + + for (const match of sql.matchAll(CTE_PATTERN)) { + const rawIdentifier = match[1]; + + if (rawIdentifier) { + cteNames.add(normalizeSqlIdentifier(rawIdentifier)); + } + } + + return cteNames; +} + +function normalizeSqlIdentifier(identifier: string): string { + return identifier + .split(".") + .map((part) => part.trim().replace(/^"|"$/g, "")) + .join("."); +} + +function mergeTables(left: string[], right: string[]): string[] { + return [...new Set([...left, ...right])].sort(); +} diff --git a/demo/ppg-dev/server.ts b/demo/ppg-dev/server.ts index 251020f3..1f6e27e6 100644 --- a/demo/ppg-dev/server.ts +++ b/demo/ppg-dev/server.ts @@ -12,9 +12,11 @@ import { type StudioLlmRequest, type StudioLlmResponse, } from "../../data/llm"; +import type { Query } from "../../data/query"; import pkg from "../../package.json" with { type: "json" }; import { AnthropicOutputLimitError, runAnthropicLlmRequest } from "./anthropic"; import { buildDemoConfig, resolveDemoAiEnabled } from "./config"; +import { createDemoQueryInsightsStore } from "./query-insights"; import { type DemoRuntime, startDemoRuntime } from "./runtime"; import { formatDemoRuntimeUsage, @@ -63,7 +65,7 @@ type BuiltAsset = { contentType: string; }; -type PostgresExecutor = DemoRuntime["postgresExecutor"]; +type PostgresExecutor = NonNullable; // When the server is bundled by build-compute.ts, the virtual:prebuilt-assets // module is resolved at bundle time and provides the pre-built client JS, CSS, @@ -153,6 +155,7 @@ let isBuildQueued = false; let rebuildTimer: ReturnType | undefined; const reloadClients = new Set>(); let queryQueue = Promise.resolve(); +const queryInsightsStore = createDemoQueryInsightsStore(); const cleanupCallbacks: Array<() => Promise | void> = []; @@ -344,6 +347,7 @@ async function handleRequest(request: Request): Promise { aiEnabled: AI_ENABLED, bootId: BOOT_ID, databaseEnabled: postgresExecutor != null, + queryInsightsEnabled: postgresExecutor != null, seededAt, streamsUrl: streamsServerUrl ? STREAMS_PROXY_BASE_PATH : undefined, }), @@ -459,9 +463,19 @@ async function handleBffQueryRequest(request: Request): Promise { } try { + if (payload.procedure === "query-insights") { + return Response.json([ + null, + queryInsightsStore.getSnapshot({ + limit: payload.limit, + since: payload.since, + }), + ]); + } + if (payload.procedure === "query") { const [error, result] = await runSerializedQuery(() => - executor.execute(payload.query), + executeAndRecordQuery(executor, payload.query), ); return Response.json([error ? serializeError(error) : null, result]); @@ -475,7 +489,7 @@ async function handleBffQueryRequest(request: Request): Promise { } const [firstError, firstResult] = await runSerializedQuery(() => - executor.execute(firstQuery), + executeAndRecordQuery(executor, firstQuery), ); if (firstError) { @@ -483,7 +497,7 @@ async function handleBffQueryRequest(request: Request): Promise { } const [secondError, secondResult] = await runSerializedQuery(() => - executor.execute(secondQuery), + executeAndRecordQuery(executor, secondQuery), ); if (secondError) { @@ -513,9 +527,33 @@ async function handleBffQueryRequest(request: Request): Promise { typeof executor.executeTransaction > = (queries, options) => executor.executeTransaction!(queries, options); - const [error, result] = await runSerializedQuery(() => - executeTransaction(payload.queries), - ); + const [error, result] = await runSerializedQuery(async () => { + const startedAt = performance.now(); + const transactionResult = await executeTransaction(payload.queries); + const durationMs = Math.max(0, performance.now() - startedAt); + + if (!transactionResult[0]) { + const [, results] = transactionResult; + const perQueryDurationMs = + payload.queries.length > 0 + ? durationMs / payload.queries.length + : 0; + + for (const [index, query] of payload.queries.entries()) { + const result = results[index]; + + if (result) { + queryInsightsStore.record({ + durationMs: perQueryDurationMs, + query, + result, + }); + } + } + } + + return transactionResult; + }); return Response.json([error ? serializeError(error) : null, result]); } @@ -544,6 +582,26 @@ async function handleBffQueryRequest(request: Request): Promise { } } +async function executeAndRecordQuery( + executor: PostgresExecutor, + query: Query, +): Promise>> { + const startedAt = performance.now(); + const result = await executor.execute(query); + const durationMs = Math.max(0, performance.now() - startedAt); + const [error, rows] = result; + + if (!error) { + queryInsightsStore.record({ + durationMs, + query, + result: rows, + }); + } + + return result; +} + async function handleAiRequest(request: Request): Promise { if (request.method === "OPTIONS") { return new Response(null, { diff --git a/docs/pr-1515/queries-detail.png b/docs/pr-1515/queries-detail.png new file mode 100644 index 00000000..3fd596b1 Binary files /dev/null and b/docs/pr-1515/queries-detail.png differ diff --git a/docs/pr-1515/queries-main.png b/docs/pr-1515/queries-main.png new file mode 100644 index 00000000..57811d3e Binary files /dev/null and b/docs/pr-1515/queries-main.png differ diff --git a/docs/pr-1515/sql-chart.png b/docs/pr-1515/sql-chart.png new file mode 100644 index 00000000..801b0ab0 Binary files /dev/null and b/docs/pr-1515/sql-chart.png differ diff --git a/eslint.config.mjs b/eslint.config.mjs index 2d8a7e25..3a2c422d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -31,7 +31,12 @@ export default tseslint.config( }, { - ignores: ["**/node_modules/**/*", "**/dist/**/*", "**/*.d.ts"], + ignores: [ + "**/.agents/**/*", + "**/node_modules/**/*", + "**/dist/**/*", + "**/*.d.ts", + ], }, { diff --git a/package.json b/package.json index fdf9fdb2..c250c5f8 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,7 @@ "@electric-sql/pglite": "0.3.15", "@eslint/eslintrc": "2.1.4", "@eslint/js": "8.57.0", - "@prisma/dev": "0.24.6", + "@prisma/dev": "0.24.8", "@radix-ui/react-alert-dialog": "1.1.15", "@radix-ui/react-checkbox": "1.3.3", "@radix-ui/react-context-menu": "2.2.16", @@ -178,6 +178,8 @@ "@tanstack/react-table": "8.21.3", "@tanstack/react-table-devtools": "8.21.3", "@types/bun": "1.3.11", + "@types/d3-array": "3.2.2", + "@types/d3-shape": "3.1.8", "@types/eslint-config-prettier": "6.11.3", "@types/eslint__js": "8.42.3", "@types/node": "25.0.9", @@ -249,7 +251,15 @@ }, "dependencies": { "@radix-ui/react-toggle": "1.1.10", - "chart.js": "4.5.1", + "@visx/curve": "4.0.1-alpha.0", + "@visx/event": "4.0.1-alpha.0", + "@visx/grid": "4.0.1-alpha.0", + "@visx/group": "4.0.1-alpha.0", + "@visx/responsive": "4.0.1-alpha.0", + "@visx/scale": "4.0.1-alpha.0", + "@visx/shape": "4.0.1-alpha.0", + "d3-array": "3.2.4", + "d3-shape": "3.2.0", "elkjs": "0.11.1" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd273c41..0653de71 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,9 +14,33 @@ dependencies: '@types/react': specifier: ^18.0.0 || ^19.0.0 version: 19.2.14 - chart.js: - specifier: 4.5.1 - version: 4.5.1 + '@visx/curve': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0 + '@visx/event': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0 + '@visx/grid': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0(react@19.2.4) + '@visx/group': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0(react@19.2.4) + '@visx/responsive': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0(react@19.2.4) + '@visx/scale': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0 + '@visx/shape': + specifier: 4.0.1-alpha.0 + version: 4.0.1-alpha.0(react@19.2.4) + d3-array: + specifier: 3.2.4 + version: 3.2.4 + d3-shape: + specifier: 3.2.0 + version: 3.2.0 elkjs: specifier: 0.11.1 version: 0.11.1 @@ -68,8 +92,8 @@ devDependencies: specifier: 8.57.0 version: 8.57.0 '@prisma/dev': - specifier: 0.24.6 - version: 0.24.6(typescript@5.9.3) + specifier: 0.24.8 + version: 0.24.8(typescript@5.9.3) '@radix-ui/react-alert-dialog': specifier: 1.1.15 version: 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.14)(react-dom@19.2.4)(react@19.2.4) @@ -133,6 +157,12 @@ devDependencies: '@types/bun': specifier: 1.3.11 version: 1.3.11 + '@types/d3-array': + specifier: 3.2.2 + version: 3.2.2 + '@types/d3-shape': + specifier: 3.1.8 + version: 3.1.8 '@types/eslint-config-prettier': specifier: 6.11.3 version: 6.11.3 @@ -1086,10 +1116,6 @@ packages: '@jridgewell/sourcemap-codec': 1.5.5 dev: true - /@kurkle/color@0.3.4: - resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} - dev: false - /@lezer/common@1.5.1: resolution: {integrity: sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw==} dev: true @@ -1166,8 +1192,8 @@ packages: resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} dev: true - /@prisma/dev@0.24.6(typescript@5.9.3): - resolution: {integrity: sha512-GbPGfHBszyfeq82xaAVtMqrJX1lzdJbu3ESnuQ84Vd4+/fZQTj930yvI4/et3c63MgjfKpchFzvDHYkHJJFjXQ==} + /@prisma/dev@0.24.8(typescript@5.9.3): + resolution: {integrity: sha512-yfAdStjQxNxot3iMaENAnWzy/pYAl50yFZXeB3LYRr94gpB5jBHwyvqRTu+bUQCr44uN/K+INSBUXrEV/7g5KQ==} dependencies: '@electric-sql/pglite': 0.4.3 '@electric-sql/pglite-socket': 0.1.3(@electric-sql/pglite@0.4.3) @@ -1175,7 +1201,7 @@ packages: '@hono/node-server': 1.19.11(hono@4.12.8) '@prisma/get-platform': 7.2.0 '@prisma/query-plan-executor': 7.2.0 - '@prisma/streams-local': 0.1.3 + '@prisma/streams-local': 0.1.9 foreground-child: 3.3.1 get-port-please: 3.2.0 hono: 4.12.8 @@ -1200,9 +1226,9 @@ packages: resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} dev: true - /@prisma/streams-local@0.1.3: - resolution: {integrity: sha512-HOxR28/DjFm/6TS/Mf51xSbx8bPtLjzQRH3j620lhugFGH6xSHYVO8yXS5JSpqX7tpLgkTWDVwUn8WQbcBUApQ==} - engines: {bun: '>=1.3.6', node: '>=22.0.0'} + /@prisma/streams-local@0.1.9: + resolution: {integrity: sha512-lI6YwthIykOXwHPgxgdfQTavc5fiTy03nRjlZeBSVK+NNp/KAX6yqN46H5gZ6BSlu+7dt+BlL8UuubUkhes2fg==} + engines: {bun: '>=1.2.0', node: '>=22.0.0'} dependencies: ajv: 8.18.0 better-result: 2.7.0 @@ -2633,6 +2659,10 @@ packages: assertion-error: 2.0.1 dev: true + /@types/d3-array@3.0.3: + resolution: {integrity: sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ==} + dev: false + /@types/d3-array@3.2.2: resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} dev: true @@ -2653,9 +2683,12 @@ packages: resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} dev: true + /@types/d3-color@3.1.0: + resolution: {integrity: sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA==} + dev: false + /@types/d3-color@3.1.3: resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} - dev: true /@types/d3-contour@3.0.6: resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} @@ -2664,6 +2697,10 @@ packages: '@types/geojson': 7946.0.16 dev: true + /@types/d3-delaunay@6.0.1: + resolution: {integrity: sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ==} + dev: false + /@types/d3-delaunay@6.0.4: resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} dev: true @@ -2696,6 +2733,10 @@ packages: resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} dev: true + /@types/d3-format@3.0.1: + resolution: {integrity: sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg==} + dev: false + /@types/d3-format@3.0.4: resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} dev: true @@ -2704,12 +2745,17 @@ packages: resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} dependencies: '@types/geojson': 7946.0.16 - dev: true /@types/d3-hierarchy@3.1.7: resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} dev: true + /@types/d3-interpolate@3.0.1: + resolution: {integrity: sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw==} + dependencies: + '@types/d3-color': 3.1.3 + dev: false + /@types/d3-interpolate@3.0.4: resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} dependencies: @@ -2718,7 +2764,6 @@ packages: /@types/d3-path@3.1.1: resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} - dev: true /@types/d3-polygon@3.0.2: resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} @@ -2736,6 +2781,12 @@ packages: resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} dev: true + /@types/d3-scale@4.0.2: + resolution: {integrity: sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA==} + dependencies: + '@types/d3-time': 3.0.4 + dev: false + /@types/d3-scale@4.0.9: resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} dependencies: @@ -2746,19 +2797,32 @@ packages: resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} dev: true + /@types/d3-shape@3.1.7: + resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==} + dependencies: + '@types/d3-path': 3.1.1 + dev: false + /@types/d3-shape@3.1.8: resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} dependencies: '@types/d3-path': 3.1.1 dev: true + /@types/d3-time-format@2.1.0: + resolution: {integrity: sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA==} + dev: false + /@types/d3-time-format@4.0.3: resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} dev: true + /@types/d3-time@3.0.0: + resolution: {integrity: sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg==} + dev: false + /@types/d3-time@3.0.4: resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} - dev: true /@types/d3-timer@3.0.2: resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} @@ -2843,7 +2907,6 @@ packages: /@types/geojson@7946.0.16: resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} - dev: true /@types/json-schema@7.0.15: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -2853,6 +2916,10 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/lodash@4.17.24: + resolution: {integrity: sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==} + dev: false + /@types/node@12.20.55: resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} dev: true @@ -3132,6 +3199,109 @@ packages: resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} dev: true + /@visx/curve@4.0.1-alpha.0: + resolution: {integrity: sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg==} + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + dev: false + + /@visx/event@4.0.1-alpha.0: + resolution: {integrity: sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g==} + dependencies: + '@types/react': 19.2.14 + '@visx/point': 4.0.1-alpha.0 + dev: false + + /@visx/grid@4.0.1-alpha.0(react@19.2.4): + resolution: {integrity: sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + dependencies: + '@types/react': 19.2.14 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.4) + '@visx/point': 4.0.1-alpha.0 + '@visx/scale': 4.0.1-alpha.0 + '@visx/shape': 4.0.1-alpha.0(react@19.2.4) + classnames: 2.5.1 + react: 19.2.4 + dev: false + + /@visx/group@4.0.1-alpha.0(react@19.2.4): + resolution: {integrity: sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + dependencies: + '@types/react': 19.2.14 + classnames: 2.5.1 + react: 19.2.4 + dev: false + + /@visx/point@4.0.1-alpha.0: + resolution: {integrity: sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg==} + dev: false + + /@visx/responsive@4.0.1-alpha.0(react@19.2.4): + resolution: {integrity: sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + dependencies: + '@types/lodash': 4.17.24 + '@types/react': 19.2.14 + lodash: 4.18.1 + react: 19.2.4 + dev: false + + /@visx/scale@4.0.1-alpha.0: + resolution: {integrity: sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A==} + dependencies: + '@visx/vendor': 4.0.0-alpha.0 + dev: false + + /@visx/shape@4.0.1-alpha.0(react@19.2.4): + resolution: {integrity: sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA==} + peerDependencies: + react: ^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0 + dependencies: + '@types/lodash': 4.17.24 + '@types/react': 19.2.14 + '@visx/curve': 4.0.1-alpha.0 + '@visx/group': 4.0.1-alpha.0(react@19.2.4) + '@visx/scale': 4.0.1-alpha.0 + '@visx/vendor': 4.0.0-alpha.0 + classnames: 2.5.1 + lodash: 4.18.1 + react: 19.2.4 + dev: false + + /@visx/vendor@4.0.0-alpha.0: + resolution: {integrity: sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ==} + dependencies: + '@types/d3-array': 3.0.3 + '@types/d3-color': 3.1.0 + '@types/d3-delaunay': 6.0.1 + '@types/d3-format': 3.0.1 + '@types/d3-geo': 3.1.0 + '@types/d3-interpolate': 3.0.1 + '@types/d3-path': 3.1.1 + '@types/d3-scale': 4.0.2 + '@types/d3-shape': 3.1.7 + '@types/d3-time': 3.0.0 + '@types/d3-time-format': 2.1.0 + d3-array: 3.2.1 + d3-color: 3.1.0 + d3-delaunay: 6.0.2 + d3-format: 3.1.0 + d3-geo: 3.1.0 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-scale: 4.0.2 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + internmap: 2.0.3 + dev: false + /@vitest/expect@4.0.17: resolution: {integrity: sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==} dependencies: @@ -3560,13 +3730,6 @@ packages: resolution: {integrity: sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==} dev: true - /chart.js@4.5.1: - resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} - engines: {pnpm: '>=8'} - dependencies: - '@kurkle/color': 0.3.4 - dev: false - /chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -3593,6 +3756,10 @@ packages: resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} dev: true + /classnames@2.5.1: + resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} + dev: false + /clean-regexp@1.0.0: resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==} engines: {node: '>=4'} @@ -3730,10 +3897,30 @@ packages: /csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + /d3-array@3.2.1: + resolution: {integrity: sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ==} + engines: {node: '>=12'} + dependencies: + internmap: 2.0.3 + dev: false + + /d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + dependencies: + internmap: 2.0.3 + dev: false + /d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} - dev: true + + /d3-delaunay@6.0.2: + resolution: {integrity: sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ==} + engines: {node: '>=12'} + dependencies: + delaunator: 5.1.0 + dev: false /d3-dispatch@3.0.1: resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} @@ -3753,18 +3940,71 @@ packages: engines: {node: '>=12'} dev: true + /d3-format@3.1.0: + resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==} + engines: {node: '>=12'} + dev: false + + /d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + dev: false + + /d3-geo@3.1.0: + resolution: {integrity: sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + /d3-interpolate@3.0.1: resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} engines: {node: '>=12'} dependencies: d3-color: 3.1.0 - dev: true + + /d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + dev: false + + /d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + dev: false /d3-selection@3.0.0: resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} engines: {node: '>=12'} dev: true + /d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + dependencies: + d3-path: 3.1.0 + dev: false + + /d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + dependencies: + d3-time: 3.1.0 + dev: false + + /d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + /d3-timer@3.0.1: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} @@ -3907,6 +4147,12 @@ packages: object-keys: 1.1.1 dev: true + /delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + dependencies: + robust-predicates: 3.0.3 + dev: false + /denque@2.1.0: resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} engines: {node: '>=0.10'} @@ -5001,6 +5247,11 @@ packages: side-channel: 1.1.0 dev: true + /internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + dev: false + /is-arguments@1.2.0: resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} @@ -5516,6 +5767,10 @@ packages: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} dev: true + /lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + dev: false + /long@5.3.2: resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} dev: true @@ -6419,6 +6674,10 @@ packages: glob: 7.2.3 dev: true + /robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + dev: false + /rollup@4.57.1: resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} diff --git a/scripts/compute-preview/compute-preview-deploy.mjs b/scripts/compute-preview/compute-preview-deploy.mjs index 02ddf356..4ce4c48c 100644 --- a/scripts/compute-preview/compute-preview-deploy.mjs +++ b/scripts/compute-preview/compute-preview-deploy.mjs @@ -18,7 +18,7 @@ async function main() { const projectName = process.env.PREVIEW_PROJECT_NAME ?? PREVIEW_PROJECT_NAME; const deployPath = process.env.PREVIEW_DEPLOY_PATH ?? "deploy"; const entrypoint = - process.env.PREVIEW_ENTRYPOINT ?? "bundle/server.bundle.js"; + process.env.PREVIEW_ENTRYPOINT ?? "bundle/compute-entrypoint.js"; const httpPort = process.env.PREVIEW_HTTP_PORT ?? "8080"; const serviceName = sanitizeComputeServiceName(branchName); @@ -37,8 +37,6 @@ async function main() { entrypoint, "--http-port", httpPort, - "--env", - `STUDIO_DEMO_PORT=${httpPort}`, "--service", service.id, ]); diff --git a/scripts/compute-preview/compute-preview-utils.test.ts b/scripts/compute-preview/compute-preview-utils.test.ts index c34d2f6b..c105380e 100644 --- a/scripts/compute-preview/compute-preview-utils.test.ts +++ b/scripts/compute-preview/compute-preview-utils.test.ts @@ -1,3 +1,6 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + import { describe, expect, it } from "vitest"; import { @@ -88,3 +91,15 @@ describe("buildPreviewCommentBody", () => { ); }); }); + +describe("compute preview deploy script", () => { + it("uses the compute entrypoint instead of passing rejected env vars to deploy", async () => { + const deployScript = await readFile( + join(import.meta.dirname, "compute-preview-deploy.mjs"), + "utf8", + ); + + expect(deployScript).toContain('"bundle/compute-entrypoint.js"'); + expect(deployScript).not.toContain('"--env"'); + }); +}); diff --git a/ui/components/charts/animation.ts b/ui/components/charts/animation.ts new file mode 100644 index 00000000..f33492c1 --- /dev/null +++ b/ui/components/charts/animation.ts @@ -0,0 +1,33 @@ +import type { Transition } from "motion/react"; + +/** Default clip-reveal easing for cartesian charts. */ +export const DEFAULT_ANIMATION_EASING = "cubic-bezier(0.85, 0, 0.15, 1)"; + +export const DEFAULT_ANIMATION_DURATION_MS = 1100; + +/** Default enter transition — matches the original line chart reveal. */ +export const DEFAULT_CHART_ENTER_TRANSITION: Transition = { + type: "tween", + duration: DEFAULT_ANIMATION_DURATION_MS / 1000, + ease: [0.85, 0, 0.15, 1], +}; + +/** + * Clip-path width reveal must use tween — spring does not reliably animate SVG width. + */ +export function clipRevealTransition(enterTransition?: Transition): Transition { + if (enterTransition?.type === "tween") { + return enterTransition; + } + + const duration = + typeof enterTransition?.duration === "number" + ? enterTransition.duration + : DEFAULT_ANIMATION_DURATION_MS / 1000; + + return { + type: "tween", + duration, + ease: DEFAULT_CHART_ENTER_TRANSITION.ease, + }; +} diff --git a/ui/components/charts/area-gradient-defs.tsx b/ui/components/charts/area-gradient-defs.tsx new file mode 100644 index 00000000..8615ff3f --- /dev/null +++ b/ui/components/charts/area-gradient-defs.tsx @@ -0,0 +1,95 @@ +import { + type FadeEdges, + fadeGradientStops, + resolveFadeSides, +} from "./fade-edges"; + +interface AreaGradientDefsProps { + gradientId: string; + strokeGradientId: string; + edgeMaskId: string; + edgeGradientId: string; + fill: string; + fillOpacity: number; + gradientToOpacity: number; + resolvedStroke: string; + isPatternFill: boolean; + fadeEdges: FadeEdges; + innerWidth: number; + innerHeight: number; +} + +export function AreaGradientDefs({ + gradientId, + strokeGradientId, + edgeMaskId, + edgeGradientId, + fill, + fillOpacity, + gradientToOpacity, + resolvedStroke, + isPatternFill, + fadeEdges, + innerWidth, + innerHeight, +}: AreaGradientDefsProps) { + const sides = resolveFadeSides(fadeEdges); + // Stroke gradient mirrors the area's edge fade so the line doesn't pop in + // past the faded fill. Skip emitting it when neither edge fades — the line + // can then paint a solid stroke instead of an unnecessary url(#...) ref. + const strokeStops = sides.any ? fadeGradientStops(sides) : null; + const showEdgeMask = sides.any && !isPatternFill; + const edgeStops = showEdgeMask ? fadeGradientStops(sides) : null; + + return ( + + {isPatternFill ? null : ( + + + + + )} + + {strokeStops ? ( + + {strokeStops.map((stop) => ( + + ))} + + ) : null} + + {edgeStops ? ( + <> + + {edgeStops.map((stop) => ( + + ))} + + + + + + ) : null} + + ); +} diff --git a/ui/components/charts/bar-chart.tsx b/ui/components/charts/bar-chart.tsx new file mode 100644 index 00000000..9581b171 --- /dev/null +++ b/ui/components/charts/bar-chart.tsx @@ -0,0 +1,613 @@ +"use client"; + +import { localPoint } from "@visx/event"; +import { ParentSize } from "@visx/responsive"; +import { scaleBand, scaleLinear } from "@visx/scale"; +import type { Transition } from "motion/react"; +import { + Children, + isValidElement, + memo, + type ReactElement, + type ReactNode, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { cn } from "@/ui/lib/utils"; + +import { DEFAULT_ANIMATION_EASING } from "./animation"; +import type { BarProps } from "./bar"; +import { + ChartProvider, + type LineConfig, + type Margin, + type TooltipData, +} from "./chart-context"; +import { isGradientDefComponent, isPatternDefComponent } from "./chart-defs"; +import { shortDateFmt } from "./chart-formatters"; +import { useScheduledTooltip } from "./use-scheduled-tooltip"; + +export type BarOrientation = "vertical" | "horizontal"; + +export interface BarChartProps { + /** Data array - each item should have an x-axis key and numeric values */ + data: Record[]; + /** Key in data for the categorical axis. Default: "name" */ + xDataKey?: string; + /** Chart margins */ + margin?: Partial; + /** Animation duration in milliseconds. Default: 1100 */ + animationDuration?: number; + /** CSS easing for bar grow transitions. */ + animationEasing?: string; + /** Motion enter transition (spring or cubic-bezier tween). */ + enterTransition?: Transition; + /** Signature of motion URL state — triggers enter replay when it changes. */ + revealSignature?: string; + /** Aspect ratio as "width / height". Default: "2 / 1" */ + aspectRatio?: string; + /** Additional class name for the container */ + className?: string; + /** Gap between bar groups as a fraction of band width (0-1). Default: 0.2 */ + barGap?: number; + /** Fixed bar width in pixels. If not set, bars auto-size to fill the band. */ + barWidth?: number; + /** Bar chart orientation. Default: "vertical" */ + orientation?: BarOrientation; + /** Whether to stack bars instead of grouping them. Default: false */ + stacked?: boolean; + /** Gap between stacked bar segments in pixels. Default: 0 */ + stackGap?: number; + /** Child components (Bar, Grid, ChartTooltip, etc.) */ + children: ReactNode; +} + +const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 }; + +// Extract bar configs from children synchronously +function extractBarConfigs(children: ReactNode): LineConfig[] { + const configs: LineConfig[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + + const childType = child.type as { + displayName?: string; + name?: string; + }; + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; + + const props = child.props as BarProps | undefined; + const isBarComponent = + componentName === "Bar" || + (props && typeof props.dataKey === "string" && props.dataKey.length > 0); + + if (isBarComponent && props?.dataKey) { + // Use stroke for tooltip dot color if provided, otherwise fall back to fill + // This allows gradient/pattern fills to have a solid dot color + const dotColor = + props.stroke || props.fill || "var(--chart-line-primary)"; + configs.push({ + dataKey: props.dataKey, + stroke: dotColor, + strokeWidth: 0, + }); + } + }); + + return configs; +} + +// Check if a component should render after the mouse overlay +function isPostOverlayComponent(child: ReactElement): boolean { + const childType = child.type as { + displayName?: string; + name?: string; + __isChartMarkers?: boolean; + }; + + if (childType.__isChartMarkers) { + return true; + } + + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; + + return componentName === "ChartMarkers" || componentName === "MarkerGroup"; +} + +interface ChartInnerProps { + width: number; + height: number; + data: Record[]; + xDataKey: string; + margin: Margin; + animationDuration: number; + animationEasing: string; + enterTransition?: Transition; + revealSignature?: string; + barGap: number; + barWidthProp?: number; + orientation: BarOrientation; + stacked: boolean; + stackGap: number; + children: ReactNode; + containerRef: React.RefObject; +} + +function ChartInner(props: ChartInnerProps) { + const { width, height } = props; + if (width < 10 || height < 10) { + return null; + } + return ; +} + +const ChartCore = memo(function ChartCore({ + width, + height, + data, + xDataKey, + margin, + animationDuration, + animationEasing, + enterTransition, + revealSignature = "", + barGap, + barWidthProp, + orientation, + stacked, + stackGap, + children, + containerRef, +}: ChartInnerProps) { + const { tooltipData, setTooltipData, scheduleTooltip, clearTooltip } = + useScheduledTooltip(); + const [isLoaded, setIsLoaded] = useState(false); + const [revealEpoch, setRevealEpoch] = useState(0); + const hoveredBarIndex = tooltipData?.index ?? null; + + const isHorizontal = orientation === "horizontal"; + + // Extract bar configs synchronously from children + const lines = useMemo(() => extractBarConfigs(children), [children]); + + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + // Category accessor function - returns string for categorical scale + const categoryAccessor = useCallback( + (d: Record): string => { + const value = d[xDataKey]; + if (value instanceof Date) { + return shortDateFmt.format(value); + } + return String(value ?? ""); + }, + [xDataKey], + ); + + // For compatibility with ChartContext, provide a Date-based xAccessor + const xAccessorDate = useCallback( + (d: Record): Date => { + const value = d[xDataKey]; + if (value instanceof Date) { + return value; + } + return new Date(); + }, + [xDataKey], + ); + + // Category scale (band) - for the categorical axis + const categoryScale = useMemo(() => { + const domain = data.map((d) => categoryAccessor(d)); + const range: [number, number] = isHorizontal + ? [0, innerHeight] + : [0, innerWidth]; + return scaleBand({ + range, + domain, + padding: barGap, + }); + }, [innerWidth, innerHeight, data, categoryAccessor, barGap, isHorizontal]); + + // Band width for bars - use prop if provided, otherwise use scale's bandwidth + const bandWidth = barWidthProp ?? categoryScale.bandwidth(); + + // Compute max value considering stacking + const maxValue = useMemo(() => { + if (stacked) { + // For stacked bars, sum all values at each data point + let max = 0; + for (const d of data) { + let sum = 0; + for (const line of lines) { + const value = d[line.dataKey]; + if (typeof value === "number") { + sum += value; + } + } + if (sum > max) { + max = sum; + } + } + return max || 100; + } + // For grouped bars, find max single value + let max = 0; + for (const line of lines) { + for (const d of data) { + const value = d[line.dataKey]; + if (typeof value === "number" && value > max) { + max = value; + } + } + } + return max || 100; + }, [data, lines, stacked]); + + // Value scale (linear) - for the value axis + const valueScale = useMemo(() => { + const range = isHorizontal ? [0, innerWidth] : [innerHeight, 0]; + return scaleLinear({ + range, + domain: [0, maxValue * 1.1], + nice: true, + }); + }, [innerWidth, innerHeight, maxValue, isHorizontal]); + + // Compute stack offsets for stacked bars + const stackOffsets = useMemo(() => { + if (!stacked) { + return undefined; + } + const offsets = new Map>(); + for (let i = 0; i < data.length; i++) { + const d = data[i]; + if (!d) { + continue; + } + const pointOffsets = new Map(); + let cumulative = 0; + for (const line of lines) { + pointOffsets.set(line.dataKey, cumulative); + const value = d[line.dataKey]; + if (typeof value === "number") { + cumulative += value; + } + } + offsets.set(i, pointOffsets); + } + return offsets; + }, [data, lines, stacked]); + + // Column width for tooltip indicator + const columnWidth = useMemo(() => { + if (data.length < 1) { + return 0; + } + return isHorizontal ? innerHeight / data.length : innerWidth / data.length; + }, [innerWidth, innerHeight, data.length, isHorizontal]); + + // Pre-compute labels for ticker animation + const dateLabels = useMemo( + () => data.map((d) => categoryAccessor(d)), + [data, categoryAccessor], + ); + + // Create a fake time scale for compatibility with ChartContext + const fakeTimeScale = useMemo(() => { + const now = Date.now(); + const start = now - data.length * 24 * 60 * 60 * 1000; + const scale = { + ...categoryScale, + domain: () => [new Date(start), new Date(now)], + range: () => [0, innerWidth] as [number, number], + invert: (x: number) => new Date(start + (x / innerWidth) * (now - start)), + copy: () => scale, + }; + return scale; + }, [categoryScale, innerWidth, data.length]); + + // Animation timing — replay when motion settings change + // biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature + useEffect(() => { + setRevealEpoch((n) => n + 1); + setIsLoaded(false); + const timer = setTimeout(() => { + setIsLoaded(true); + }, animationDuration); + return () => clearTimeout(timer); + }, [animationDuration, revealSignature]); + + // Mouse move handler + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const point = localPoint(event); + if (!point) { + return; + } + + const pos = isHorizontal ? point.y - margin.top : point.x - margin.left; + + // Find which band the mouse is over + const bandIndex = Math.floor(pos / columnWidth); + const clampedIndex = Math.max(0, Math.min(data.length - 1, bandIndex)); + const d = data[clampedIndex]; + + if (!d) { + return; + } + + // Calculate positions for each bar + const yPositions: Record = {}; + const xPositions: Record = {}; + const barPos = categoryScale(categoryAccessor(d)) ?? 0; + + if (isHorizontal) { + // Horizontal bars: dots at end of bar (x = value), centered vertically in band + const seriesCount = lines.length; + const groupGap = seriesCount > 1 ? 4 : 0; + const individualBarHeight = + seriesCount > 0 + ? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount + : bandWidth; + + if (stacked) { + // Stacked horizontal: all bars same y, x at cumulative end + let cumulative = 0; + for (const line of lines) { + const value = d[line.dataKey]; + if (typeof value === "number") { + cumulative += value; + xPositions[line.dataKey] = valueScale(cumulative) ?? 0; + yPositions[line.dataKey] = barPos + bandWidth / 2; + } + } + } else { + // Grouped horizontal: each bar at its own y position + lines.forEach((line, idx) => { + const value = d[line.dataKey]; + if (typeof value === "number") { + xPositions[line.dataKey] = valueScale(value) ?? 0; + yPositions[line.dataKey] = + barPos + + idx * (individualBarHeight + groupGap) + + individualBarHeight / 2; + } + }); + } + } else if (stacked) { + // Vertical stacked bars + let cumulative = 0; + let seriesIdx = 0; + for (const line of lines) { + const value = d[line.dataKey]; + if (typeof value === "number") { + cumulative += value; + const gapOffset = seriesIdx * stackGap; + yPositions[line.dataKey] = + (valueScale(cumulative) ?? 0) - gapOffset; + seriesIdx++; + } + } + } else { + // Vertical grouped bars + const seriesCount = lines.length; + const groupGap = seriesCount > 1 ? 4 : 0; + const individualBarWidth = + seriesCount > 0 + ? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount + : bandWidth; + + lines.forEach((line, idx) => { + const value = d[line.dataKey]; + if (typeof value === "number") { + yPositions[line.dataKey] = valueScale(value) ?? 0; + xPositions[line.dataKey] = + barPos + + idx * (individualBarWidth + groupGap) + + individualBarWidth / 2; + } + }); + } + + // Tooltip position: for horizontal, position at max bar end; for vertical, center of band + let tooltipX: number; + if (isHorizontal) { + // Position tooltip at the end of the longest bar + const maxX = Math.max(...Object.values(xPositions), 0); + tooltipX = maxX; + } else { + tooltipX = barPos + bandWidth / 2; + } + + scheduleTooltip({ + point: d, + index: clampedIndex, + x: tooltipX, + yPositions, + xPositions: Object.keys(xPositions).length > 0 ? xPositions : undefined, + }); + }, + [ + categoryScale, + valueScale, + data, + lines, + margin.left, + margin.top, + categoryAccessor, + columnWidth, + bandWidth, + isHorizontal, + stacked, + stackGap, + scheduleTooltip, + ], + ); + + const handleMouseLeave = useCallback(() => { + clearTooltip(); + }, [clearTooltip]); + + const canInteract = isLoaded; + + // Separate children into defs, pre-overlay, and post-overlay + const defsChildren: ReactElement[] = []; + const preOverlayChildren: ReactElement[] = []; + const postOverlayChildren: ReactElement[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + return; + } + + if (isGradientDefComponent(child)) { + defsChildren.push(child); + } else if (isPatternDefComponent(child)) { + preOverlayChildren.push(child); + } else if (isPostOverlayComponent(child)) { + postOverlayChildren.push(child); + } else { + preOverlayChildren.push(child); + } + }); + + const contextValue = { + data, + renderData: data, + xScale: fakeTimeScale as unknown as ReturnType< + typeof import("@visx/scale").scaleTime + >, + yScale: valueScale, + width, + height, + innerWidth, + innerHeight, + margin, + columnWidth, + tooltipData, + setTooltipData, + containerRef, + lines, + isLoaded, + animationDuration, + animationEasing, + enterTransition, + revealEpoch, + xAccessor: xAccessorDate, + dateLabels, + // Bar-specific properties + barScale: categoryScale, + bandWidth, + hoveredBarIndex, + barXAccessor: categoryAccessor, + orientation, + stacked, + stackOffsets, + }; + + return ( + + + + ); +}); + +export function BarChart({ + data, + xDataKey = "name", + margin: marginProp, + animationDuration = 1100, + animationEasing = DEFAULT_ANIMATION_EASING, + enterTransition, + revealSignature, + aspectRatio = "2 / 1", + className = "", + barGap = 0.2, + barWidth, + orientation = "vertical", + stacked = false, + stackGap = 0, + children, +}: BarChartProps) { + const containerRef = useRef(null); + const margin = { ...DEFAULT_MARGIN, ...marginProp }; + + return ( +
+ + {({ width, height }) => ( + + {children} + + )} + +
+ ); +} + +BarChart.displayName = "BarChart"; + +export default BarChart; diff --git a/ui/components/charts/bar-x-axis.tsx b/ui/components/charts/bar-x-axis.tsx new file mode 100644 index 00000000..e02050b1 --- /dev/null +++ b/ui/components/charts/bar-x-axis.tsx @@ -0,0 +1,155 @@ +"use client"; + +import { motion } from "motion/react"; +import { memo, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; + +import { cn } from "@/ui/lib/utils"; + +import { useChart, useChartStable } from "./chart-context"; + +export interface BarXAxisProps { + /** Width of the date ticker box for fade calculation. Default: 50 */ + tickerHalfWidth?: number; + /** Whether to show all labels or skip some for dense data. Default: false */ + showAllLabels?: boolean; + /** Maximum number of labels to show. Default: 12 */ + maxLabels?: number; +} + +interface BarXAxisLabelProps { + label: string; + x: number; + crosshairX: number | null; + isHovering: boolean; + tickerHalfWidth: number; +} + +function BarXAxisLabel({ + label, + x, + crosshairX, + isHovering, + tickerHalfWidth, +}: BarXAxisLabelProps) { + const fadeBuffer = 20; + const fadeRadius = tickerHalfWidth + fadeBuffer; + + let opacity = 1; + if (isHovering && crosshairX !== null) { + const distance = Math.abs(x - crosshairX); + if (distance < tickerHalfWidth) { + opacity = 0; + } else if (distance < fadeRadius) { + opacity = (distance - tickerHalfWidth) / fadeBuffer; + } + } + + // Zero-width container approach for perfect centering + return ( +
+ + {label} + +
+ ); +} + +export function BarXAxis(props: BarXAxisProps) { + const { containerRef, barScale } = useChartStable(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + if (!barScale) { + return null; + } + + return ; +} + +const BarXAxisInner = memo(function BarXAxisInner({ + tickerHalfWidth = 50, + showAllLabels = false, + maxLabels = 12, + container, +}: BarXAxisProps & { container: HTMLDivElement }) { + const { margin, tooltipData, barScale, bandWidth, barXAccessor, data } = + useChart(); + + // Generate labels for each bar + const labelsToShow = useMemo(() => { + if (!(barScale && bandWidth && barXAccessor)) { + return []; + } + + const allLabels = data.map((d) => { + const label = barXAccessor(d); + const bandX = barScale(label) ?? 0; + // Center the label under the bar group + const x = bandX + bandWidth / 2 + margin.left; + return { label, x }; + }); + + // If showAllLabels is true or we have fewer than maxLabels, show all + if (showAllLabels || allLabels.length <= maxLabels) { + return allLabels; + } + + // Otherwise, skip some labels to avoid crowding + const step = Math.ceil(allLabels.length / maxLabels); + return allLabels.filter((_, i) => i % step === 0); + }, [ + barScale, + bandWidth, + barXAccessor, + data, + margin.left, + showAllLabels, + maxLabels, + ]); + + const isHovering = tooltipData !== null; + const crosshairX = tooltipData ? tooltipData.x + margin.left : null; + + return createPortal( +
+ {labelsToShow.map((item) => ( + + ))} +
, + container, + ); +}); + +BarXAxis.displayName = "BarXAxis"; + +export default BarXAxis; diff --git a/ui/components/charts/bar-y-axis.tsx b/ui/components/charts/bar-y-axis.tsx new file mode 100644 index 00000000..bc620af6 --- /dev/null +++ b/ui/components/charts/bar-y-axis.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { motion } from "motion/react"; +import { memo, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; + +import { cn } from "@/ui/lib/utils"; + +import { useChart, useChartStable } from "./chart-context"; + +export interface BarYAxisProps { + /** Whether to show all labels or skip some for dense data. Default: true */ + showAllLabels?: boolean; + /** Maximum number of labels to show. Default: 20 */ + maxLabels?: number; +} + +interface BarYAxisLabelProps { + label: string; + y: number; + bandHeight: number; + isHovered: boolean; +} + +function BarYAxisLabel({ + label, + y, + bandHeight, + isHovered, +}: BarYAxisLabelProps) { + return ( +
+ + {label} + +
+ ); +} + +export function BarYAxis(props: BarYAxisProps) { + const { containerRef, barScale } = useChartStable(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + if (!barScale) { + return null; + } + + return ; +} + +const BarYAxisInner = memo(function BarYAxisInner({ + showAllLabels = true, + maxLabels = 20, + container, +}: BarYAxisProps & { container: HTMLDivElement }) { + const { margin, barScale, bandWidth, barXAccessor, data, hoveredBarIndex } = + useChart(); + + // Generate labels for each bar + const labelsToShow = useMemo(() => { + if (!(barScale && bandWidth && barXAccessor)) { + return []; + } + + const allLabels = data.map((d, i) => { + const label = barXAccessor(d); + const bandY = barScale(label) ?? 0; + // Center the label vertically within the band + const y = bandY + margin.top; + return { label, y, bandHeight: bandWidth, index: i }; + }); + + // If showAllLabels is true or we have fewer than maxLabels, show all + if (showAllLabels || allLabels.length <= maxLabels) { + return allLabels; + } + + // Otherwise, skip some labels to avoid crowding + const step = Math.ceil(allLabels.length / maxLabels); + return allLabels.filter((_, i) => i % step === 0); + }, [ + barScale, + bandWidth, + barXAccessor, + data, + margin.top, + showAllLabels, + maxLabels, + ]); + + return createPortal( +
+ {labelsToShow.map((item) => ( + + ))} +
, + container, + ); +}); + +BarYAxis.displayName = "BarYAxis"; + +export default BarYAxis; diff --git a/ui/components/charts/bar.tsx b/ui/components/charts/bar.tsx new file mode 100644 index 00000000..9f831f32 --- /dev/null +++ b/ui/components/charts/bar.tsx @@ -0,0 +1,355 @@ +"use client"; + +import type { scaleBand } from "@visx/scale"; +import type { Transition } from "motion/react"; +import { motion } from "motion/react"; +import { memo, useId, useMemo } from "react"; + +import { chartCssVars, useChart, useChartStable } from "./chart-context"; +import { transitionWithDelay } from "./motion-utils"; + +type ScaleBand = ReturnType< + typeof scaleBand +>; + +export type BarLineCap = "round" | "butt" | number; +export type BarAnimationType = "grow" | "fade"; + +export interface BarProps { + /** Key in data to use for y values */ + dataKey: string; + /** Fill color for the bar. Can be a color, gradient url, or pattern url. Default: var(--chart-line-primary) */ + fill?: string; + /** Color for tooltip dot. Use when fill is a gradient/pattern. Default: uses fill value */ + stroke?: string; + /** Line cap style for bar ends: "round", "butt", or a number for custom radius. Default: "round" */ + lineCap?: BarLineCap; + /** Whether to animate the bars. Default: true */ + animate?: boolean; + /** Animation type: "grow" (height) or "fade" (opacity + blur). Default: "grow" */ + animationType?: BarAnimationType; + /** Opacity when not hovered (when another bar is hovered). Default: 0.3 */ + fadedOpacity?: number; + /** Stagger delay between bars in seconds. Auto-calculated if not provided. */ + staggerDelay?: number; + /** Gap between stacked bars in pixels. Default: 0 */ + stackGap?: number; + /** Gap between grouped bars in pixels. Default: 4 */ + groupGap?: number; +} + +interface BarInnerProps extends BarProps { + barScale: ScaleBand; + bandWidth: number; + barXAccessor: (d: Record) => string; +} + +interface AnimatedBarProps { + x: number; + y: number; + width: number; + height: number; + fill: string; + rx: number; + ry: number; + index: number; + isFaded: boolean; + animationType: BarAnimationType; + innerHeight: number; + fadedOpacity: number; + staggerDelay: number; + enterTransition?: Transition; + revealEpoch: number; + isHorizontal: boolean; +} + +function AnimatedBar({ + x, + y, + width, + height, + fill, + rx, + ry, + index, + isFaded, + animationType, + innerHeight, + fadedOpacity, + staggerDelay, + enterTransition, + revealEpoch, + isHorizontal, +}: AnimatedBarProps) { + const enterAnim = transitionWithDelay(enterTransition, index * staggerDelay); + + if (animationType === "fade") { + return ( + + ); + } + + const initial = isHorizontal + ? { width: 0, height, x: 0, y } + : { width, height: 0, x, y: innerHeight }; + const target = isHorizontal + ? { width, height, x: 0, y } + : { width, height, x, y }; + + return ( + + + + ); +} + +const BarInner = memo(function BarInner({ + dataKey, + fill = chartCssVars.linePrimary, + lineCap = "round", + animate = true, + animationType = "grow", + fadedOpacity = 0.3, + staggerDelay, + stackGap = 0, + groupGap = 4, + barScale, + bandWidth, + barXAccessor, +}: BarInnerProps) { + const { + data, + yScale, + innerHeight, + isLoaded, + hoveredBarIndex, + lines, + orientation, + stacked, + stackOffsets, + animationDuration, + enterTransition, + revealEpoch = 0, + } = useChart(); + + // Calculate stagger delay automatically if not provided + // Total animation duration is ~1200ms, with 40% for stagger spread and 60% for bar animation + const totalAnimDuration = animationDuration || 1100; + const staggerSpread = totalAnimDuration * 0.4; // 40% of time for stagger spread + const calculatedStaggerDelay = + staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0); + const uniqueId = useId(); + + const isHorizontal = orientation === "horizontal"; + + // Find the index of this bar series among all bar series + const seriesIndex = useMemo(() => { + const idx = lines.findIndex((l) => l.dataKey === dataKey); + return idx >= 0 ? idx : 0; + }, [lines, dataKey]); + + const seriesCount = lines.length; + const isLastSeries = seriesIndex === seriesCount - 1; + + // Calculate the width for each bar within a group (for non-stacked) + const barWidth = useMemo(() => { + if (!bandWidth || seriesCount === 0) { + return 0; + } + if (stacked) { + // Stacked bars use full band width + return bandWidth; + } + // Leave a gap between grouped bars (controlled by groupGap prop) + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0; + return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount; + }, [bandWidth, seriesCount, stacked, groupGap]); + + // Calculate corner radius based on lineCap + const cornerRadius = useMemo(() => { + if (typeof lineCap === "number") { + return lineCap; + } + if (lineCap === "round" && barWidth) { + return Math.min(barWidth / 2, 8); + } + return 0; + }, [lineCap, barWidth]); + + return ( + + {data.map((d, i) => { + const value = d[dataKey]; + if (typeof value !== "number") { + return null; + } + + const categoryValue = barXAccessor(d); + const bandPos = barScale(categoryValue) ?? 0; + + let x: number; + let y: number; + let barHeight: number; + let barW: number; + + if (isHorizontal) { + // Horizontal bars: category on y-axis, value on x-axis + const valuePos = yScale(value) ?? 0; + barW = valuePos; // Width is the value position (grows from left) + barHeight = barWidth; + + if (stacked && stackOffsets) { + const offset = stackOffsets.get(i)?.get(dataKey) ?? 0; + x = yScale(offset) ?? 0; + barW = valuePos - x; + // Apply stack gap for horizontal: shift right and reduce width + const gapOffset = seriesIndex * stackGap; + x += gapOffset; + if (!isLastSeries && stackGap > 0) { + barW = Math.max(0, barW - stackGap); + } + } else { + x = 0; + // For grouped bars, offset y position + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0; + y = bandPos + seriesIndex * (barWidth + effectiveGroupGap); + } + y = stacked + ? bandPos + : bandPos + + seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0)); + } else { + // Vertical bars: category on x-axis, value on y-axis + const valuePos = yScale(value) ?? 0; + barHeight = innerHeight - valuePos; + barW = barWidth; + + if (stacked && stackOffsets) { + const offset = stackOffsets.get(i)?.get(dataKey) ?? 0; + const offsetY = yScale(offset) ?? innerHeight; + // Apply stack gap: shift up and reduce height + const gapOffset = seriesIndex * stackGap; + y = offsetY - barHeight - gapOffset; + // Reduce height slightly for non-last bars to create visual gap + if (!isLastSeries && stackGap > 0) { + barHeight = Math.max(0, barHeight - stackGap); + } + } else { + y = valuePos; + // For grouped bars, offset x position + const effectiveGroupGap = seriesCount > 1 ? groupGap : 0; + x = bandPos + seriesIndex * (barWidth + effectiveGroupGap); + } + x = stacked + ? bandPos + : bandPos + + seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0)); + } + + const isFaded = hoveredBarIndex !== null && hoveredBarIndex !== i; + + // Use categoryValue as key since it's the unique identifier from data + const barKey = `bar-${dataKey}-${categoryValue}`; + + // Apply rounded corners: + // - For non-stacked: always apply + // - For stacked with gap: apply to all bars + // - For stacked without gap: only apply to the last series + const applyRounding = !stacked || stackGap > 0 || isLastSeries; + const effectiveRx = applyRounding ? cornerRadius : 0; + const effectiveRy = applyRounding ? cornerRadius : 0; + + if (animate && !isLoaded) { + return ( + + ); + } + + // Static bar after animation completes + return ( + + ); + })} + + ); +}); + +export function Bar(props: BarProps) { + const { barScale, bandWidth, barXAccessor } = useChartStable(); + + if (!(barScale && bandWidth && barXAccessor)) { + console.warn("Bar component must be used within a BarChart"); + return null; + } + + return ( + + ); +} + +Bar.displayName = "Bar"; + +export default Bar; diff --git a/ui/components/charts/chart-center-typography.ts b/ui/components/charts/chart-center-typography.ts new file mode 100644 index 00000000..2b10c25e --- /dev/null +++ b/ui/components/charts/chart-center-typography.ts @@ -0,0 +1,10 @@ +export const chartCenterContainerClassName = + "@container/chart-center size-full min-w-0"; + +/** Primary stat — ~22% of center width, clamped between text-sm and text-3xl. */ +export const chartCenterValueClassName = + "font-bold tabular-nums leading-none text-[clamp(0.75rem,22cqw,1.875rem)]"; + +/** Supporting label — ~9% of center width, clamped between 10px and text-xs. */ +export const chartCenterLabelClassName = + "max-w-full truncate leading-tight text-[clamp(0.625rem,9cqw,0.75rem)]"; diff --git a/ui/components/charts/chart-config-context.tsx b/ui/components/charts/chart-config-context.tsx new file mode 100644 index 00000000..26d830a9 --- /dev/null +++ b/ui/components/charts/chart-config-context.tsx @@ -0,0 +1,53 @@ +"use client"; + +import { createContext, type ReactNode, useContext, useMemo } from "react"; + +export interface SpringConfig { + stiffness: number; + damping: number; +} + +export interface ChartConfigValue { + /** Crosshair indicator, tooltip dot, date pill. */ + tooltipSpring: SpringConfig; + /** Floating tooltip panel. */ + tooltipBoxSpring: SpringConfig; + /** Line/area hover-highlight band (x + width). */ + highlightSpring: SpringConfig; +} + +export const DEFAULT_CHART_CONFIG: ChartConfigValue = { + tooltipSpring: { stiffness: 300, damping: 30 }, + tooltipBoxSpring: { stiffness: 100, damping: 20 }, + highlightSpring: { stiffness: 180, damping: 28 }, +}; + +const ChartConfigContext = createContext(null); + +export interface ChartConfigProviderProps { + value?: Partial; + children: ReactNode; +} + +export function ChartConfigProvider({ + value, + children, +}: ChartConfigProviderProps) { + const merged = useMemo( + () => ({ + ...DEFAULT_CHART_CONFIG, + ...value, + }), + [value], + ); + + return ( + + {children} + + ); +} + +export function useChartConfig(): ChartConfigValue { + return useContext(ChartConfigContext) ?? DEFAULT_CHART_CONFIG; +} diff --git a/ui/components/charts/chart-context.tsx b/ui/components/charts/chart-context.tsx new file mode 100644 index 00000000..bf471e03 --- /dev/null +++ b/ui/components/charts/chart-context.tsx @@ -0,0 +1,362 @@ +"use client"; + +import type { scaleBand, scaleLinear, scaleTime } from "@visx/scale"; + +type ScaleLinear = ReturnType< + typeof scaleLinear +>; +type ScaleTime = ReturnType< + typeof scaleTime +>; +type ScaleBand = ReturnType< + typeof scaleBand +>; + +import type { Transition } from "motion/react"; +import { + createContext, + type Dispatch, + type ReactNode, + type RefObject, + type SetStateAction, + useContext, + useMemo, +} from "react"; + +import type { ChartSelection } from "./use-chart-interaction"; + +// CSS variable references for theming +export const chartCssVars = { + background: "var(--chart-background)", + foreground: "var(--chart-foreground)", + foregroundMuted: "var(--chart-foreground-muted)", + label: "var(--chart-label)", + linePrimary: "var(--chart-line-primary)", + lineSecondary: "var(--chart-line-secondary)", + crosshair: "var(--chart-crosshair)", + grid: "var(--chart-grid)", + indicatorColor: "var(--chart-indicator-color)", + indicatorSecondaryColor: "var(--chart-indicator-secondary-color)", + markerBackground: "var(--chart-marker-background)", + markerBorder: "var(--chart-marker-border)", + markerForeground: "var(--chart-marker-foreground)", + badgeBackground: "var(--chart-marker-badge-background)", + badgeForeground: "var(--chart-marker-badge-foreground)", + segmentBackground: "var(--chart-segment-background)", + segmentLine: "var(--chart-segment-line)", +}; + +/** Default scatter series colors from the chart palette (`--chart-1` … `--chart-5`). */ +export const defaultScatterColors = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +] as const; + +export interface Margin { + top: number; + right: number; + bottom: number; + left: number; +} + +export interface TooltipData { + /** The data point being hovered */ + point: Record; + /** Index in the data array */ + index: number; + /** X position in pixels (relative to chart area) */ + x: number; + /** Y positions for each line, keyed by dataKey */ + yPositions: Record; + /** X positions for each series (for grouped bars), keyed by dataKey */ + xPositions?: Record; +} + +export interface LineConfig { + dataKey: string; + stroke: string; + strokeWidth: number; +} + +/** + * Hover/selection state — every field here changes on mouse movement. + * Lives in its own context so cold consumers (Grid, YAxis, PatternArea, …) + * can subscribe to the stable slice and skip re-rendering on every hover. + */ +export interface ChartHoverContextValue { + // Tooltip state + tooltipData: TooltipData | null; + setTooltipData: Dispatch>; + + // Selection state (optional - only present when useChartInteraction is used) + /** Current drag/pinch selection range */ + selection?: ChartSelection | null; + /** Clear the current selection */ + clearSelection?: () => void; + + // Bar chart hover (optional - only present in BarChart) + /** Index of currently hovered bar */ + hoveredBarIndex?: number | null; + /** Setter for hovered bar index */ + setHoveredBarIndex?: (index: number | null) => void; + + // Candlestick hover (optional - only present in CandlestickChart) + /** Index of currently hovered candle */ + hoveredCandleIndex?: number | null; + /** Setter for hovered candle index */ + setHoveredCandleIndex?: (index: number | null) => void; +} + +export interface ChartContextValue extends ChartHoverContextValue { + // Data + data: Record[]; + /** Decimated subset for SVG path rendering; equals `data` when no decimation is needed. */ + renderData: Record[]; + + // Scales + xScale: ScaleTime; + yScale: ScaleLinear; + + // Dimensions + width: number; + height: number; + innerWidth: number; + innerHeight: number; + margin: Margin; + + // Column width for spacing calculations + columnWidth: number; + + // Container ref for portals + containerRef: RefObject; + + // Line configurations (extracted from children) + lines: LineConfig[]; + + // Animation state + isLoaded: boolean; + animationDuration: number; + /** CSS easing for clip-reveal / line draw (cartesian charts). */ + animationEasing?: string; + /** Motion enter transition (spring or tween) — drives clip reveal when spring. */ + enterTransition?: Transition; + /** Increments when enter animation should replay. */ + revealEpoch?: number; + + // X accessor - how to get the x value from data points + xAccessor: (d: Record) => Date; + + // Pre-computed date labels for ticker animation + dateLabels: string[]; + + // Bar chart specific (optional - only present in BarChart) + /** Band scale for categorical x-axis (bar charts) */ + barScale?: ScaleBand; + /** Width of each bar band */ + bandWidth?: number; + /** X accessor for bar charts (returns string instead of Date) */ + barXAccessor?: (d: Record) => string; + /** Bar chart orientation */ + orientation?: "vertical" | "horizontal"; + /** Whether bars are stacked */ + stacked?: boolean; + /** Stack offsets: Map of data index -> Map of dataKey -> cumulative offset */ + stackOffsets?: Map>; + + // ComposedChart + SeriesBar (optional) + /** `SeriesBar` dataKeys in tree order, for grouped columns at each x */ + composedBarDataKeys?: string[]; + /** Target bar width in px (Recharts `barSize` style). */ + composedBarSize?: number; + /** Max bar width in px (Recharts `maxBarSize`). */ + composedMaxBarSize?: number; + /** Gap between grouped `SeriesBar` columns in px. */ + composedBarGap?: number; + /** When true, `SeriesBar` segments stack in child order at each x. */ + composedStacked?: boolean; + /** Per-row cumulative offsets for stacked `SeriesBar` (data index → dataKey → offset). */ + composedStackOffsets?: Map>; + /** Vertical gap in px between stacked `SeriesBar` segments. Default: 0 */ + composedStackGap?: number; +} + +/** + * Stable slice of the chart context — everything that doesn't change on hover + * (data, scales, dimensions, animation state, layout config). Consumers that + * subscribe via `useChartStable()` skip re-renders on every mouse move. + */ +export type ChartStableContextValue = Omit< + ChartContextValue, + keyof ChartHoverContextValue +>; + +const ChartStableContext = createContext(null); +const ChartHoverContext = createContext(null); + +/** + * Splits the merged `value` into a stable slice and a volatile hover slice, + * publishing each to its own context. Each slice is memoized on its own + * field identities, so changing `tooltipData` does not bust the stable + * slice — consumers of `useChartStable()` skip re-renders on hover. + */ +export function ChartProvider({ + children, + value, +}: { + children: ReactNode; + value: ChartContextValue; +}) { + const stable = useMemo( + () => ({ + data: value.data, + renderData: value.renderData, + xScale: value.xScale, + yScale: value.yScale, + width: value.width, + height: value.height, + innerWidth: value.innerWidth, + innerHeight: value.innerHeight, + margin: value.margin, + columnWidth: value.columnWidth, + containerRef: value.containerRef, + lines: value.lines, + isLoaded: value.isLoaded, + animationDuration: value.animationDuration, + animationEasing: value.animationEasing, + enterTransition: value.enterTransition, + revealEpoch: value.revealEpoch, + xAccessor: value.xAccessor, + dateLabels: value.dateLabels, + barScale: value.barScale, + bandWidth: value.bandWidth, + barXAccessor: value.barXAccessor, + orientation: value.orientation, + stacked: value.stacked, + stackOffsets: value.stackOffsets, + composedBarDataKeys: value.composedBarDataKeys, + composedBarSize: value.composedBarSize, + composedMaxBarSize: value.composedMaxBarSize, + composedBarGap: value.composedBarGap, + composedStacked: value.composedStacked, + composedStackOffsets: value.composedStackOffsets, + composedStackGap: value.composedStackGap, + }), + [ + value.data, + value.renderData, + value.xScale, + value.yScale, + value.width, + value.height, + value.innerWidth, + value.innerHeight, + value.margin, + value.columnWidth, + value.containerRef, + value.lines, + value.isLoaded, + value.animationDuration, + value.animationEasing, + value.enterTransition, + value.revealEpoch, + value.xAccessor, + value.dateLabels, + value.barScale, + value.bandWidth, + value.barXAccessor, + value.orientation, + value.stacked, + value.stackOffsets, + value.composedBarDataKeys, + value.composedBarSize, + value.composedMaxBarSize, + value.composedBarGap, + value.composedStacked, + value.composedStackOffsets, + value.composedStackGap, + ], + ); + + const hover = useMemo( + () => ({ + tooltipData: value.tooltipData, + setTooltipData: value.setTooltipData, + selection: value.selection, + clearSelection: value.clearSelection, + hoveredBarIndex: value.hoveredBarIndex, + setHoveredBarIndex: value.setHoveredBarIndex, + hoveredCandleIndex: value.hoveredCandleIndex, + setHoveredCandleIndex: value.setHoveredCandleIndex, + }), + [ + value.tooltipData, + value.setTooltipData, + value.selection, + value.clearSelection, + value.hoveredBarIndex, + value.setHoveredBarIndex, + value.hoveredCandleIndex, + value.setHoveredCandleIndex, + ], + ); + + return ( + + + {children} + + + ); +} + +/** + * Stable slice — data, scales, dimensions, animation state, layout config. + * Subscribers skip re-renders on hover (the hover slice lives in a separate + * context). Prefer this in cold consumers like axes, grid, pattern fills. + */ +export function useChartStable(): ChartStableContextValue { + const context = useContext(ChartStableContext); + if (!context) { + throw new Error( + "useChartStable must be used within a ChartProvider. " + + "Make sure your component is wrapped in , , , or .", + ); + } + return context; +} + +/** + * Hover slice — tooltipData, selection, hovered bar / candle indices. + * Subscribers re-render on every mouse move. Use only when the component + * actually reads hover state. + */ +export function useChartHover(): ChartHoverContextValue { + const context = useContext(ChartHoverContext); + if (!context) { + throw new Error( + "useChartHover must be used within a ChartProvider. " + + "Make sure your component is wrapped in , , , or .", + ); + } + return context; +} + +/** + * Merged stable + hover context. Convenient for components that need both, + * but re-renders on every hover (because hover changes). Prefer + * `useChartStable()` or `useChartHover()` for hot consumers that only need + * one slice. + */ +export function useChart(): ChartContextValue { + const stable = useChartStable(); + const hover = useChartHover(); + // Identity changes on every hover (hover is the volatile slice) — that's + // fine for consumers using this merged hook; they explicitly opted in to + // re-rendering on hover. + return { ...stable, ...hover }; +} + +export default ChartStableContext; diff --git a/ui/components/charts/chart-defs.ts b/ui/components/charts/chart-defs.ts new file mode 100644 index 00000000..6dd941a6 --- /dev/null +++ b/ui/components/charts/chart-defs.ts @@ -0,0 +1,72 @@ +import { + Children, + isValidElement, + type ReactElement, + type ReactNode, +} from "react"; + +export function getChartChildComponentName(child: ReactElement): string { + const childType = child.type as { displayName?: string; name?: string }; + return typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; +} + +const VISX_PATTERN_COMPONENT_NAMES = new Set([ + "Lines", + "Circles", + "Waves", + "Hexagons", + "Path", + "Pattern", +]); + +/** @visx/pattern default exports use short names (e.g. `Lines`); also match *Pattern* displayNames. */ +export function isPatternDefComponent(child: ReactElement): boolean { + const name = getChartChildComponentName(child); + return name.includes("Pattern") || VISX_PATTERN_COMPONENT_NAMES.has(name); +} + +export function isGradientDefComponent(child: ReactElement): boolean { + const name = getChartChildComponentName(child); + return ( + name.includes("Gradient") || + name === "LinearGradient" || + name === "RadialGradient" + ); +} + +export function isChartDefsComponent(child: ReactElement): boolean { + return isPatternDefComponent(child) || isGradientDefComponent(child); +} + +/** Split hoisted defs: @visx/pattern nodes already wrap `` and render at the svg root. */ +export function partitionChartDefNodes(defNodes: ReactElement[]): { + patternDefNodes: ReactElement[]; + gradientDefNodes: ReactElement[]; +} { + const patternDefNodes: ReactElement[] = []; + const gradientDefNodes: ReactElement[] = []; + + for (const node of defNodes) { + if (isPatternDefComponent(node)) { + patternDefNodes.push(node); + } else { + gradientDefNodes.push(node); + } + } + + return { patternDefNodes, gradientDefNodes }; +} + +export function collectChartDefsChildren(children: ReactNode): ReactElement[] { + const defNodes: ReactElement[] = []; + + Children.forEach(children, (child) => { + if (isValidElement(child) && isChartDefsComponent(child)) { + defNodes.push(child); + } + }); + + return defNodes; +} diff --git a/ui/components/charts/chart-formatters.ts b/ui/components/charts/chart-formatters.ts new file mode 100644 index 00000000..90ceb4cc --- /dev/null +++ b/ui/components/charts/chart-formatters.ts @@ -0,0 +1,23 @@ +export const shortDateFmt = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", +}); + +export const weekdayDateFmt = new Intl.DateTimeFormat("en-US", { + weekday: "short", + month: "short", + day: "numeric", +}); + +export const hmsTimeFmt = new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, +}); + +const intFormatter = new Intl.NumberFormat("en-US"); + +export function intFmt(value: number): string { + return intFormatter.format(value); +} diff --git a/ui/components/charts/chart-reveal-clip.tsx b/ui/components/charts/chart-reveal-clip.tsx new file mode 100644 index 00000000..95abe2fa --- /dev/null +++ b/ui/components/charts/chart-reveal-clip.tsx @@ -0,0 +1,49 @@ +"use client"; + +import type { Transition } from "motion/react"; +import { motion } from "motion/react"; + +import { clipRevealTransition } from "./animation"; + +export interface ChartRevealClipProps { + clipPathId: string; + height: number; + targetWidth: number; + enterTransition?: Transition; + /** Bumps when motion settings change to replay the reveal. */ + revealEpoch: number; + /** Extra inset around the clip rect so edge glyphs are not cut off. */ + padding?: number; +} + +/** + * Left-to-right clip reveal for cartesian series. + * Grows clip rect width from 0 → full (true LTR; scaleX is avoided — it reveals from center). + */ +export function ChartRevealClip({ + clipPathId, + height, + targetWidth, + enterTransition, + revealEpoch, + padding = 0, +}: ChartRevealClipProps) { + const transition = clipRevealTransition(enterTransition); + const paddedWidth = Math.max(0, targetWidth + padding * 2); + const paddedHeight = height + padding * 2; + + return ( + + + + ); +} diff --git a/ui/components/charts/dash-tail-stroke.tsx b/ui/components/charts/dash-tail-stroke.tsx new file mode 100644 index 00000000..becbca5f --- /dev/null +++ b/ui/components/charts/dash-tail-stroke.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useId } from "react"; + +export interface DashTailStrokeProps { + /** SVG path `d` for the full series (single curved path). */ + pathD: string | null; + /** Total length of `pathD` in user units. */ + pathLength: number; + /** Path length at which the dashed tail begins. */ + dashStartLength: number; + /** X coordinate (chart inner space) where the tail clip begins. */ + dashStartX: number; + innerWidth: number; + innerHeight: number; + /** Stroke paint — solid color or gradient url. */ + stroke: string; + strokeWidth: number; + dashArray: string; +} + +export function DashTailStroke({ + pathD, + pathLength, + dashStartLength, + dashStartX, + innerWidth, + innerHeight, + stroke, + strokeWidth, + dashArray, +}: DashTailStrokeProps) { + const clipPathId = useId().replace(/:/g, ""); + + if (!pathD || pathLength <= 0 || dashStartLength >= pathLength) { + return null; + } + + const pad = strokeWidth * 2; + const tailWidth = Math.max(0, innerWidth - dashStartX + pad); + + return ( + <> + + + + + + {/* Solid head — same curved path, gradient/fade preserved */} + + {/* Dashed tail — clipped to x ≥ dashStartX so dashes follow the curve */} + + + ); +} diff --git a/ui/components/charts/decimate-time-series.ts b/ui/components/charts/decimate-time-series.ts new file mode 100644 index 00000000..2d05d600 --- /dev/null +++ b/ui/components/charts/decimate-time-series.ts @@ -0,0 +1,136 @@ +export function decimateTimeSeries>( + data: T[], + maxPoints: number, + valueKeys: string[] = [], +): T[] { + const len = data.length; + if (maxPoints >= len || maxPoints < 3) { + return data; + } + + const getY = (point: T, index: number): number => { + if (valueKeys.length === 0) { + for (const val of Object.values(point)) { + if (typeof val === "number") { + return val; + } + } + return index; + } + + let sum = 0; + let count = 0; + for (const key of valueKeys) { + const val = point[key]; + if (typeof val === "number") { + sum += val; + count++; + } + } + return count > 0 ? sum / count : index; + }; + + const sampled: T[] = [data[0] as T]; + const bucketSize = (len - 2) / (maxPoints - 2); + let previousIndex = 0; + + for (let i = 0; i < maxPoints - 2; i++) { + const rangeStart = Math.floor((i + 1) * bucketSize) + 1; + const rangeEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, len - 1); + + const nextRangeStart = Math.floor((i + 2) * bucketSize) + 1; + const nextRangeEnd = Math.min(Math.floor((i + 3) * bucketSize) + 1, len); + const nextCount = Math.max(0, nextRangeEnd - nextRangeStart); + + let avgX = len - 1; + let avgY = getY(data[len - 1] as T, len - 1); + if (nextCount > 0) { + avgX = 0; + avgY = 0; + for (let j = nextRangeStart; j < nextRangeEnd; j++) { + avgX += j; + avgY += getY(data[j] as T, j); + } + avgX /= nextCount; + avgY /= nextCount; + } + + const pointA = data[previousIndex] as T; + const ax = previousIndex; + const ay = getY(pointA, previousIndex); + + let maxArea = -1; + let maxIndex = rangeStart; + + for (let j = rangeStart; j < rangeEnd; j++) { + const area = + Math.abs( + (ax - avgX) * (getY(data[j] as T, j) - ay) - (ax - j) * (avgY - ay), + ) * 0.5; + if (area > maxArea) { + maxArea = area; + maxIndex = j; + } + } + + sampled.push(data[maxIndex] as T); + previousIndex = maxIndex; + } + + sampled.push(data[len - 1] as T); + return sampled; +} + +/** ~1.5 points per pixel — enough for crisp curves without over-drawing. */ +export function maxRenderPointsForWidth(innerWidth: number): number { + return Math.max(64, Math.ceil(innerWidth * 1.5)); +} + +/** Bucket OHLC rows into fewer candles while preserving high/low extremes. */ +export function decimateOhlcData>( + data: T[], + maxPoints: number, +): T[] { + const len = data.length; + if (maxPoints >= len || maxPoints < 2) { + return data; + } + + const bucketSize = len / maxPoints; + const sampled: T[] = []; + + for (let i = 0; i < maxPoints; i++) { + const start = Math.floor(i * bucketSize); + const end = Math.min(len, Math.floor((i + 1) * bucketSize)); + if (start >= end) { + continue; + } + + const bucket = data.slice(start, end); + const first = bucket[0] as T; + const last = bucket.at(-1) as T; + + let high = Number.NEGATIVE_INFINITY; + let low = Number.POSITIVE_INFINITY; + for (const row of bucket) { + const rowHigh = row.high; + const rowLow = row.low; + if (typeof rowHigh === "number" && rowHigh > high) { + high = rowHigh; + } + if (typeof rowLow === "number" && rowLow < low) { + low = rowLow; + } + } + + sampled.push({ + ...last, + open: first.open, + high: Number.isFinite(high) ? high : last.high, + low: Number.isFinite(low) ? low : last.low, + close: last.close, + } as T); + } + + return sampled; +} diff --git a/ui/components/charts/fade-edges.ts b/ui/components/charts/fade-edges.ts new file mode 100644 index 00000000..99cbad07 --- /dev/null +++ b/ui/components/charts/fade-edges.ts @@ -0,0 +1,41 @@ +export type FadeEdges = boolean | "left" | "right"; + +export interface FadeSides { + /** Whether the left edge should fade out. */ + left: boolean; + /** Whether the right edge should fade out. */ + right: boolean; + /** True if either side fades — use to gate gradient/mask defs. */ + any: boolean; +} + +export function resolveFadeSides(fade: FadeEdges): FadeSides { + if (fade === false) { + return { left: false, right: false, any: false }; + } + if (fade === "left") { + return { left: true, right: false, any: true }; + } + if (fade === "right") { + return { left: false, right: true, any: true }; + } + return { left: true, right: true, any: true }; +} + +export interface FadeGradientStop { + offset: string; + opacity: number; +} + +/** + * Stops for a horizontal fade gradient with opacity 0 at the faded side(s) + * and opacity 1 in the middle. Matches the historic 0/15/85/100 pattern. + */ +export function fadeGradientStops(sides: FadeSides): FadeGradientStop[] { + return [ + { offset: "0%", opacity: sides.left ? 0 : 1 }, + { offset: "15%", opacity: 1 }, + { offset: "85%", opacity: 1 }, + { offset: "100%", opacity: sides.right ? 0 : 1 }, + ]; +} diff --git a/ui/components/charts/grid.tsx b/ui/components/charts/grid.tsx new file mode 100644 index 00000000..3c95cddb --- /dev/null +++ b/ui/components/charts/grid.tsx @@ -0,0 +1,189 @@ +"use client"; + +import { GridColumns, GridRows } from "@visx/grid"; +import { useId } from "react"; + +import { chartCssVars, useChartStable } from "./chart-context"; + +export interface GridProps { + /** Show horizontal grid lines. Default: true */ + horizontal?: boolean; + /** Show vertical grid lines. Default: false */ + vertical?: boolean; + /** Number of horizontal grid lines. Default: 5 */ + numTicksRows?: number; + /** Number of vertical grid lines. Default: 10 */ + numTicksColumns?: number; + /** Explicit tick values for horizontal grid lines. Overrides numTicksRows. */ + rowTickValues?: number[]; + /** Grid line stroke color. Default: var(--chart-grid) */ + stroke?: string; + /** Grid line stroke opacity. Default: 1 */ + strokeOpacity?: number; + /** Grid line stroke width. Default: 1 */ + strokeWidth?: number; + /** Grid line dash array. Default: "4,4" for dashed lines */ + strokeDasharray?: string; + /** Horizontal row values rendered with alternate styling (e.g. zero baseline). */ + highlightRowValues?: number[]; + /** Stroke for highlighted rows. Default: var(--chart-foreground-muted) */ + highlightRowStroke?: string; + /** Stroke opacity for highlighted rows. Default: 1 */ + highlightRowStrokeOpacity?: number; + /** Stroke width for highlighted rows. Default: 1 */ + highlightRowStrokeWidth?: number; + /** Dash array for highlighted rows. Default: solid line */ + highlightRowStrokeDasharray?: string; + /** Enable horizontal fade effect on grid rows (fades at left/right). Default: true */ + fadeHorizontal?: boolean; + /** Enable vertical fade effect on grid columns (fades at top/bottom). Default: false */ + fadeVertical?: boolean; +} + +export function Grid({ + horizontal = true, + vertical = false, + numTicksRows = 5, + numTicksColumns = 10, + rowTickValues, + stroke = chartCssVars.grid, + strokeOpacity = 1, + strokeWidth = 1, + strokeDasharray = "4,4", + highlightRowValues, + highlightRowStroke = chartCssVars.foregroundMuted, + highlightRowStrokeOpacity = 1, + highlightRowStrokeWidth = 1, + highlightRowStrokeDasharray = "0", + fadeHorizontal = true, + fadeVertical = false, +}: GridProps) { + const { xScale, yScale, innerWidth, innerHeight, orientation, barScale } = + useChartStable(); + + // For bar charts, determine which scale to use for grid lines + // Horizontal bar charts: vertical grid should use yScale (value scale) + // Vertical bar charts: horizontal grid uses yScale (value scale) + const isHorizontalBarChart = orientation === "horizontal" && barScale; + + // For vertical grid lines in horizontal bar charts, use yScale (the value scale) + // For time-based charts, use xScale + const columnScale = isHorizontalBarChart ? yScale : xScale; + const uniqueId = useId(); + + // Horizontal fade mask (for grid rows - fades left/right) + const hMaskId = `grid-rows-fade-${uniqueId}`; + const hGradientId = `${hMaskId}-gradient`; + + // Vertical fade mask (for grid columns - fades top/bottom) + const vMaskId = `grid-cols-fade-${uniqueId}`; + const vGradientId = `${vMaskId}-gradient`; + + return ( + + {/* Gradient mask for horizontal grid lines - fades at left/right */} + {horizontal && fadeHorizontal && ( + + + + + + + + + + + + )} + + {/* Gradient mask for vertical grid lines - fades at top/bottom */} + {vertical && fadeVertical && ( + + + + + + + + + + + + )} + + {horizontal && ( + + + + )} + {horizontal && highlightRowValues && highlightRowValues.length > 0 ? ( + + {highlightRowValues.map((value) => { + const y = yScale(value); + if (y == null || !Number.isFinite(y)) { + return null; + } + + return ( + + ); + })} + + ) : null} + {vertical && columnScale && typeof columnScale === "function" && ( + + + + )} + + ); +} + +Grid.displayName = "Grid"; + +export default Grid; diff --git a/ui/components/charts/highlight-segment-bounds.ts b/ui/components/charts/highlight-segment-bounds.ts new file mode 100644 index 00000000..d63965b1 --- /dev/null +++ b/ui/components/charts/highlight-segment-bounds.ts @@ -0,0 +1,71 @@ +import type { TooltipData } from "./chart-context"; +import type { ChartSelection } from "./use-chart-interaction"; + +// Pure geometry for the hover-highlight band, split out from the hook so it can +// be unit-tested without React/motion (see __tests__). +// +// The band is the pixel x-range one data point either side of the hovered point: +// [ xScale(t(idx-1)), xScale(t(idx+1)) ] +// `` then re-strokes the base path clipped to that band, so the +// highlight always traces the line itself. Selecting the band by data index +// assumes x is monotone along the path, which holds for a time series. On a curve +// that overshoots in x (curveNatural, curveBasis) a band edge can land a few +// pixels short, slightly narrowing the bright slice but never detaching it. + +export interface SegmentBounds { + /** Left edge of the highlight band, in pixels. */ + x: number; + /** Width of the highlight band, in pixels. */ + width: number; + isActive: boolean; +} + +export const INACTIVE_SEGMENT: SegmentBounds = { + x: 0, + width: 0, + isActive: false, +}; + +/** + * The highlight band `{x, width}` in pixel space, from the data + `xScale` plus + * the current hover/selection. Hover spans one data point either side of the dot + * (clamped to the ends); an active drag-selection uses the dragged pixel range + * directly and takes priority over hover. + */ +export function computeSegmentBounds( + data: Record[], + xScale: (value: Date) => number | undefined, + xAccessor: (d: Record) => Date, + tooltipData: Pick | null | undefined, + selection: + | Pick + | null + | undefined, +): SegmentBounds { + if (data.length === 0) { + return INACTIVE_SEGMENT; + } + + if (selection?.active) { + const x = Math.min(selection.startX, selection.endX); + const width = Math.abs(selection.endX - selection.startX); + return { x, width, isActive: true }; + } + + if (!tooltipData) { + return INACTIVE_SEGMENT; + } + + const idx = tooltipData.index; + const startIdx = Math.max(0, idx - 1); + const endIdx = Math.min(data.length - 1, idx + 1); + const startPoint = data[startIdx]; + const endPoint = data[endIdx]; + if (!(startPoint && endPoint)) { + return INACTIVE_SEGMENT; + } + + const startX = xScale(xAccessor(startPoint)) ?? 0; + const endX = xScale(xAccessor(endPoint)) ?? 0; + return { x: startX, width: Math.max(0, endX - startX), isActive: true }; +} diff --git a/ui/components/charts/highlight-segment.tsx b/ui/components/charts/highlight-segment.tsx new file mode 100644 index 00000000..074a8ab2 --- /dev/null +++ b/ui/components/charts/highlight-segment.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { motion, type MotionValue } from "motion/react"; +import { type RefObject, useId } from "react"; + +// Hover-highlight overlay: re-strokes the base path `d`, clipped to a vertical +// band whose x/width spring to track the hovered point, so only the segment +// around the dot shows brighter. The band comes from `useHighlightSegment`; +// because the bright stroke reuses the base `d`, it follows whatever curve is +// drawn (see `highlight-segment-bounds.ts` for the band-extent caveat). + +export interface HighlightSegmentProps { + /** Ref to the rendered base stroke `` — its `d` is re-used verbatim. */ + pathRef: RefObject; + /** Whether to render (caller gates on showHighlight + active + loaded). */ + visible: boolean; + stroke: string; + strokeWidth: number; + /** Plot height — the clip band spans it fully. */ + height: number; + /** Spring-eased left edge of the clip band (px). */ + x: MotionValue; + /** Spring-eased width of the clip band (px). */ + width: MotionValue; +} + +export function HighlightSegment({ + pathRef, + visible, + stroke, + strokeWidth, + height, + x, + width, +}: HighlightSegmentProps) { + const clipId = useId(); + if (!(visible && pathRef.current)) { + return null; + } + return ( + <> + + + + + + + + ); +} + +HighlightSegment.displayName = "HighlightSegment"; + +export default HighlightSegment; diff --git a/ui/components/charts/line-chart.tsx b/ui/components/charts/line-chart.tsx new file mode 100644 index 00000000..3a088ccc --- /dev/null +++ b/ui/components/charts/line-chart.tsx @@ -0,0 +1,205 @@ +"use client"; + +import { ParentSize } from "@visx/responsive"; +import type { Transition } from "motion/react"; +import { + Children, + isValidElement, + type ReactElement, + type ReactNode, + useMemo, + useRef, +} from "react"; + +import { cn } from "@/ui/lib/utils"; + +import type { LineConfig, Margin } from "./chart-context"; +import { Line, type LineProps } from "./line"; +import { TimeSeriesChartInner } from "./time-series-chart-shell"; + +export interface LineChartProps { + /** Data array - each item should have a date field and numeric values */ + data: Record[]; + /** Key in data for the x-axis (date). Default: "date" */ + xDataKey?: string; + /** Chart margins */ + margin?: Partial; + /** Animation duration in milliseconds. Default: 1100 */ + animationDuration?: number; + /** CSS easing for clip-reveal. Default: cubic-bezier(0.85, 0, 0.15, 1) */ + animationEasing?: string; + enterTransition?: Transition; + revealSignature?: string; + /** Aspect ratio as "width / height". Default: "2 / 1" */ + aspectRatio?: string; + /** Additional class name for the container */ + className?: string; + /** Child components (Line, Grid, ChartTooltip, etc.) */ + children: ReactNode; +} + +const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 }; + +/** Series renderers that carry a dataKey but must not drive the shared y-domain. */ +const LINE_DOMAIN_EXCLUDED_NAMES = new Set([ + "ProfitLossLine", + "Area", + "SeriesBar", + "Scatter", + "Candlestick", + "Bar", + "PatternArea", +]); + +function getChildComponentName(child: ReactElement) { + const childType = child.type as { displayName?: string; name?: string }; + return typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; +} + +function registersLineDomain( + child: ReactElement, + props: LineProps | undefined, +) { + if (!props?.dataKey) { + return false; + } + + const componentName = getChildComponentName(child); + if (componentName === "Line" || child.type === Line) { + return true; + } + if (LINE_DOMAIN_EXCLUDED_NAMES.has(componentName)) { + return false; + } + // MDX / duplicate bundle instances may not share the same `Line` reference. + return typeof props.dataKey === "string" && props.dataKey.length > 0; +} + +function extractLineConfigs(children: ReactNode): LineConfig[] { + const configs: LineConfig[] = []; + + const visit = (node: ReactNode) => { + Children.forEach(node, (child) => { + if (!isValidElement(child)) { + return; + } + + const props = child.props as LineProps | undefined; + + if (registersLineDomain(child, props) && props?.dataKey) { + configs.push({ + dataKey: props.dataKey, + stroke: props.stroke || "var(--chart-line-primary)", + strokeWidth: props.strokeWidth || 2.5, + }); + return; + } + + const childProps = child.props as { children?: ReactNode } | undefined; + if (childProps?.children) { + visit(childProps.children); + } + }); + }; + + visit(children); + return configs; +} + +interface ChartInnerProps { + width: number; + height: number; + data: Record[]; + xDataKey: string; + margin: Margin; + animationDuration: number; + animationEasing?: string; + enterTransition?: Transition; + revealSignature?: string; + children: ReactNode; + containerRef: React.RefObject; +} + +function ChartInner({ + width, + height, + data, + xDataKey, + margin, + animationDuration, + animationEasing, + enterTransition, + revealSignature, + children, + containerRef, +}: ChartInnerProps) { + const lines = useMemo(() => extractLineConfigs(children), [children]); + + return ( + + {children} + + ); +} + +export function LineChart({ + data, + xDataKey = "date", + margin: marginProp, + animationDuration = 1100, + animationEasing, + enterTransition, + revealSignature, + aspectRatio = "2 / 1", + className = "", + children, +}: LineChartProps) { + const containerRef = useRef(null); + const margin = { ...DEFAULT_MARGIN, ...marginProp }; + + return ( +
+ + {({ width, height }) => ( + + {children} + + )} + +
+ ); +} + +export { Line, type LineProps } from "./line"; + +export default LineChart; diff --git a/ui/components/charts/line.tsx b/ui/components/charts/line.tsx new file mode 100644 index 00000000..15ce7551 --- /dev/null +++ b/ui/components/charts/line.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { curveNatural } from "@visx/curve"; +import { LinePath } from "@visx/shape"; +import type { CurveFactory } from "d3-shape"; +import { useCallback, useId, useRef } from "react"; + +import { chartCssVars, useChartStable } from "./chart-context"; +import { + type FadeEdges, + fadeGradientStops, + resolveFadeSides, +} from "./fade-edges"; +import { + resolveDashTailBounds, + usePathStrokeMetrics, +} from "./path-stroke-utils"; +import { SeriesDashTailOverlay } from "./series-dash-tail-overlay"; +import { SeriesHighlightLayer } from "./series-highlight-layer"; +import { SeriesHoverDim } from "./series-hover-dim"; +import { SeriesMarkers } from "./series-markers"; +import type { SeriesPointMarkerStyle } from "./series-point-marker"; + +export interface LineProps { + /** Key in data to use for y values */ + dataKey: string; + /** Stroke color. Default: var(--chart-line-primary) */ + stroke?: string; + /** Stroke width. Default: 2.5 */ + strokeWidth?: number; + /** Curve function. Default: curveNatural */ + curve?: CurveFactory; + /** Whether to animate the line. Default: true */ + animate?: boolean; + /** + * Fade the line stroke toward transparent at the chart edges. + * - `true` fades both edges, `false` disables the fade entirely. + * - `"left"` / `"right"` fades only that side. + * Default: true + */ + fadeEdges?: FadeEdges; + /** Whether to show highlight segment on hover. Default: true */ + showHighlight?: boolean; + /** Render scatter-style circle markers at each data point. Default: false */ + showMarkers?: boolean; + /** Marker styling (same options as Scatter). */ + markers?: SeriesPointMarkerStyle; + /** + * Data index from which the line stroke becomes dashed (inclusive). + * Useful for projecting incomplete periods, e.g. dashed from yesterday through today. + */ + dashFromIndex?: number; + /** Dash pattern for the tail segment when `dashFromIndex` is set. Default: "6,4" */ + dashArray?: string; +} + +export function Line({ + dataKey, + stroke = chartCssVars.linePrimary, + strokeWidth = 2.5, + curve = curveNatural, + animate = true, + fadeEdges = true, + showHighlight = true, + showMarkers = false, + markers, + dashFromIndex, + dashArray = "6,4", +}: LineProps) { + // Stable slice only: hover state lives inside `` and + // `` so this component (and its expensive + // child) does not re-render on cursor motion. + // The reveal-clip is now a single shared clipPath at the chart-shell + // level (`time-series-chart-shell.tsx`); we no longer render a per-line + // `` or read `revealEpoch` here. + const { + data, + renderData, + xScale, + yScale, + innerHeight, + innerWidth, + xAccessor, + } = useChartStable(); + + const pathRef = useRef(null); + const { pathLength, pathD } = usePathStrokeMetrics(pathRef, [ + renderData, + innerWidth, + dashFromIndex, + animate, + ]); + + const reactId = useId(); + const gradientId = `line-gradient-${dataKey}-${reactId}`; + + const getY = useCallback( + (d: Record) => { + const value = d[dataKey]; + return typeof value === "number" ? (yScale(value) ?? 0) : 0; + }, + [dataKey, yScale], + ); + + const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length); + const fadeSides = resolveFadeSides(fadeEdges); + const lineStroke = fadeSides.any ? `url(#${gradientId})` : stroke; + const fadeStops = fadeSides.any ? fadeGradientStops(fadeSides) : null; + + return ( + <> + {fadeStops ? ( + + + {fadeStops.map((stop) => ( + + ))} + + + ) : null} + + + xScale(xAccessor(d)) ?? 0} + y={getY} + /> + + + + + {showMarkers ? ( + + ) : null} + + + + ); +} + +Line.displayName = "Line"; + +export default Line; diff --git a/ui/components/charts/motion-utils.ts b/ui/components/charts/motion-utils.ts new file mode 100644 index 00000000..5968e5f7 --- /dev/null +++ b/ui/components/charts/motion-utils.ts @@ -0,0 +1,59 @@ +import type { Transition } from "motion/react"; + +import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation"; + +export function transitionWithDelay( + transition: Transition | undefined, + delaySeconds: number, + fallback: Transition = DEFAULT_CHART_ENTER_TRANSITION, +): Transition { + const base = transition ?? fallback; + return { ...base, delay: delaySeconds }; +} + +export interface SpringOptions { + stiffness: number; + damping: number; + mass?: number; +} + +export function springOptionsFromTransition( + transition?: Transition, + fallback: SpringOptions = { stiffness: 60, damping: 20 }, +): SpringOptions { + if (!transition) { + return fallback; + } + if (transition.type === "spring") { + const bounce = + typeof transition.bounce === "number" ? transition.bounce : undefined; + const baseStiffness = + typeof transition.stiffness === "number" + ? transition.stiffness + : fallback.stiffness; + const baseDamping = + typeof transition.damping === "number" + ? transition.damping + : fallback.damping; + return { + stiffness: + bounce == null + ? baseStiffness + : Math.min(400, Math.max(80, baseStiffness * (1 + bounce * 0.35))), + damping: + bounce == null + ? baseDamping + : Math.max(8, baseDamping * (1 - bounce * 0.25)), + mass: + typeof transition.mass === "number" ? transition.mass : fallback.mass, + }; + } + const duration = + "duration" in transition && typeof transition.duration === "number" + ? transition.duration + : 0.8; + return { + stiffness: Math.min(500, Math.max(40, 280 / duration)), + damping: Math.min(40, Math.max(12, 18 + duration * 4)), + }; +} diff --git a/ui/components/charts/path-stroke-utils.ts b/ui/components/charts/path-stroke-utils.ts new file mode 100644 index 00000000..53118c33 --- /dev/null +++ b/ui/components/charts/path-stroke-utils.ts @@ -0,0 +1,89 @@ +import { type RefObject, useEffect, useState } from "react"; + +export function findPathLengthAtX( + path: SVGPathElement | null, + pathLength: number, + targetX: number, +): number { + if (!path || pathLength === 0) { + return 0; + } + let low = 0; + let high = pathLength; + const tolerance = 0.5; + + while (high - low > tolerance) { + const mid = (low + high) / 2; + const point = path.getPointAtLength(mid); + if (point.x < targetX) { + low = mid; + } else { + high = mid; + } + } + return (low + high) / 2; +} + +interface PathStrokeMetrics { + pathD: string | null; + pathLength: number; +} + +const EMPTY_METRICS: PathStrokeMetrics = { pathD: null, pathLength: 0 }; + +/** + * Caller passes the references that drive the rendered path (renderData, + * innerWidth, etc.) as `deps`. A stringified summary like + * `${renderData.length}:${innerWidth}` is *not* safe here — same-length + * in-place mutations of `renderData` keep the summary identical, so the + * effect would never re-fire and `pathD`/`pathLength` would stay frozen on + * the previous geometry (the area fill repaints from `renderData` directly + * and would diverge from the stroke). + */ +export function usePathStrokeMetrics( + pathRef: RefObject, + deps: readonly unknown[], +): PathStrokeMetrics { + const [metrics, setMetrics] = useState(EMPTY_METRICS); + + useEffect(() => { + const path = pathRef.current; + if (!path) { + return; + } + const d = path.getAttribute("d"); + const len = d ? path.getTotalLength() : 0; + setMetrics((prev) => + prev.pathD === d && prev.pathLength === len + ? prev + : { pathD: d, pathLength: len }, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return metrics; +} + +export function resolveDashTailBounds( + dashFromIndex: number | undefined, + dataLength: number, +): boolean { + return ( + dashFromIndex != null && + dashFromIndex >= 0 && + dashFromIndex < dataLength - 1 + ); +} + +export function resolveDashStartX( + data: Record[], + dashFromIndex: number, + xScale: (value: Date | number) => number | undefined, + xAccessor: (datum: Record) => Date | number, +): number { + const dashFromPoint = data[dashFromIndex]; + if (!dashFromPoint) { + return 0; + } + return xScale(xAccessor(dashFromPoint)) ?? 0; +} diff --git a/ui/components/charts/pie-chart.tsx b/ui/components/charts/pie-chart.tsx new file mode 100644 index 00000000..b9302965 --- /dev/null +++ b/ui/components/charts/pie-chart.tsx @@ -0,0 +1,400 @@ +"use client"; + +import { Group } from "@visx/group"; +import { ParentSize } from "@visx/responsive"; +import { pie as d3Pie } from "d3-shape"; +import type { Transition } from "motion/react"; +import { + Children, + isValidElement, + memo, + type ReactElement, + type ReactNode, + useCallback, + useMemo, + useRef, + useState, +} from "react"; + +import { cn } from "@/ui/lib/utils"; + +import { + defaultPieColors, + type PieArcData, + type PieContextValue, + type PieData, + PieProvider, +} from "./pie-context"; + +/** Default hover offset in pixels */ +export const DEFAULT_HOVER_OFFSET = 10; + +export interface PieChartProps { + /** Data array - each item represents a slice */ + data: PieData[]; + /** Chart size in pixels. If not provided, uses parent container size */ + size?: number; + /** Inner radius for donut charts. Default: 0 (solid pie) */ + innerRadius?: number; + /** Padding angle between slices in radians. Default: 0 */ + padAngle?: number; + /** Corner radius for rounded slice edges. Default: 0 */ + cornerRadius?: number; + /** Start angle in radians. Default: -PI/2 (top) */ + startAngle?: number; + /** End angle in radians. Default: 3*PI/2 (full circle from top) */ + endAngle?: number; + /** Additional class name for the container */ + className?: string; + /** Controlled hover state - index of hovered slice */ + hoveredIndex?: number | null; + /** Callback when hover state changes */ + onHoverChange?: (index: number | null) => void; + /** + * Hover offset in pixels for slice hover effects. + * This also determines the padding around the chart to prevent clipping. + * Default: 10 + */ + hoverOffset?: number; + /** Child components (PieSlice, PieCenter, patterns, gradients, etc.) */ + children: ReactNode; + /** Framer Motion transition for slice enter animation */ + enterTransition?: Transition; + /** Scales slice stagger delays (1 = default). */ + enterStaggerScale?: number; +} + +interface PieChartInnerProps { + width: number; + height: number; + data: PieData[]; + innerRadius: number; + padAngle: number; + cornerRadius: number; + startAngle: number; + endAngle: number; + hoverOffset: number; + children: ReactNode; + containerRef: React.RefObject; + hoveredIndexProp?: number | null; + onHoverChange?: (index: number | null) => void; + enterTransition?: Transition; + enterStaggerScale: number; +} + +// Helper to check if a child is a PieCenter component +function isPieCenter(child: ReactNode): boolean { + return ( + isValidElement(child) && + typeof child.type === "function" && + ((child.type as { displayName?: string }).displayName === "PieCenter" || + (child.type as { name?: string }).name === "PieCenter") + ); +} + +// Helper to check if a component is a gradient or pattern definition +function isDefsComponent(child: ReactElement): boolean { + const displayName = + (child.type as { displayName?: string })?.displayName || + (child.type as { name?: string })?.name || + ""; + return ( + displayName.includes("Gradient") || + displayName.includes("Pattern") || + displayName === "LinearGradient" || + displayName === "RadialGradient" + ); +} + +function PieChartInner(props: PieChartInnerProps) { + const size = Math.min(props.width, props.height); + + if (size < 10) { + return null; + } + + return ; +} + +const PieChartCore = memo(function PieChartCore({ + width, + height, + data, + innerRadius: innerRadiusProp, + padAngle, + cornerRadius, + startAngle, + endAngle, + hoverOffset, + children, + containerRef, + hoveredIndexProp, + onHoverChange, + enterTransition, + enterStaggerScale, +}: PieChartInnerProps) { + const [internalHoveredIndex, setInternalHoveredIndex] = useState< + number | null + >(null); + const [animationKey] = useState(0); + const [isLoaded, setIsLoaded] = useState(false); + + // Use controlled or uncontrolled hover state + const isControlled = hoveredIndexProp !== undefined; + const hoveredIndex = isControlled ? hoveredIndexProp : internalHoveredIndex; + const setHoveredIndex = useCallback( + (index: number | null) => { + if (isControlled) { + onHoverChange?.(index); + } else { + setInternalHoveredIndex(index); + } + }, + [isControlled, onHoverChange], + ); + + // Use the smaller dimension to ensure the chart fits + const size = Math.min(width, height); + const center = size / 2; + + // Calculate radii with padding based on hover offset to prevent clipping + const padding = hoverOffset; + const outerRadius = center - padding; + const innerRadius = innerRadiusProp; + + // Calculate total value + const totalValue = useMemo( + () => data.reduce((sum, d) => sum + d.value, 0), + [data], + ); + + // Get color for a slice index + const getColor = useCallback( + (index: number) => { + const item = data[index]; + if (item?.color) { + return item.color; + } + return defaultPieColors[index % defaultPieColors.length] as string; + }, + [data], + ); + + // Get fill for a slice index (supports patterns/gradients) + const getFill = useCallback( + (index: number) => { + const item = data[index]; + // Check for explicit fill (pattern/gradient URL) + if (item?.fill) { + return item.fill; + } + // Fall back to color + return getColor(index); + }, + [data, getColor], + ); + + // Compute arcs using d3-shape pie + const arcs = useMemo(() => { + const pieGenerator = d3Pie() + .value((d) => d.value) + .startAngle(startAngle) + .endAngle(endAngle) + .padAngle(padAngle) + .sort(null); // Maintain data order + + const computed = pieGenerator(data); + + return computed.map((arc, index) => ({ + data: arc.data, + index, + startAngle: arc.startAngle, + endAngle: arc.endAngle, + padAngle: arc.padAngle, + value: arc.value, + })) as PieArcData[]; + }, [data, startAngle, endAngle, padAngle]); + + // Mark as loaded after initial render + useState(() => { + const timer = setTimeout(() => { + setIsLoaded(true); + }, 100); + return () => clearTimeout(timer); + }); + + // Separate children into categories + const { svgChildren, centerChildren, defsChildren } = useMemo(() => { + const svgNodes: ReactNode[] = []; + const centerNodes: ReactNode[] = []; + const defsNodes: ReactElement[] = []; + + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + svgNodes.push(child); + return; + } + + if (isPieCenter(child)) { + centerNodes.push(child); + } else if (isDefsComponent(child)) { + defsNodes.push(child); + } else { + svgNodes.push(child); + } + }); + + return { + svgChildren: svgNodes, + centerChildren: centerNodes, + defsChildren: defsNodes, + }; + }, [children]); + + const contextValue: PieContextValue = { + data, + arcs, + size, + center, + outerRadius, + innerRadius, + padAngle, + cornerRadius, + hoverOffset, + hoveredIndex, + setHoveredIndex, + animationKey, + isLoaded, + enterTransition, + enterStaggerScale, + containerRef, + totalValue, + getColor, + getFill, + }; + + // Use CSS Grid stacking to layer SVG and HTML content + // This avoids Safari's foreignObject rendering bugs + return ( + +
+ {/* SVG layer with pie slices */} + + + {/* HTML layer with center content - stacked on top via grid */} + {centerChildren.length > 0 && ( +
+ {centerChildren} +
+ )} +
+
+ ); +}); + +export function PieChart({ + data, + size: fixedSize, + innerRadius = 0, + padAngle = 0, + cornerRadius = 0, + startAngle = -Math.PI / 2, + endAngle = (3 * Math.PI) / 2, + className = "", + hoveredIndex, + onHoverChange, + hoverOffset = DEFAULT_HOVER_OFFSET, + enterTransition, + enterStaggerScale = 1, + children, +}: PieChartProps) { + const containerRef = useRef(null); + + // If fixed size is provided, use it directly + if (fixedSize) { + return ( +
+ + {children} + +
+ ); + } + + // Otherwise use ParentSize for responsive sizing + return ( +
+ + {({ width, height }) => ( + + {children} + + )} + +
+ ); +} + +PieChart.displayName = "PieChart"; + +export default PieChart; diff --git a/ui/components/charts/pie-context.tsx b/ui/components/charts/pie-context.tsx new file mode 100644 index 00000000..486f06d3 --- /dev/null +++ b/ui/components/charts/pie-context.tsx @@ -0,0 +1,112 @@ +"use client"; + +import type { Transition } from "motion/react"; +import { createContext, type RefObject, useContext } from "react"; + +// CSS variable references for pie chart theming +export const pieCssVars = { + background: "var(--chart-background)", + foreground: "var(--chart-foreground)", + foregroundMuted: "var(--chart-foreground-muted)", + label: "var(--chart-label)", + // Default slice colors from chart palette + slice1: "var(--chart-1)", + slice2: "var(--chart-2)", + slice3: "var(--chart-3)", + slice4: "var(--chart-4)", + slice5: "var(--chart-5)", +}; + +// Default slice color palette +export const defaultPieColors = [ + pieCssVars.slice1, + pieCssVars.slice2, + pieCssVars.slice3, + pieCssVars.slice4, + pieCssVars.slice5, +]; + +export interface PieData { + /** Display label for the slice */ + label: string; + /** Value for the slice (determines slice size relative to total) */ + value: number; + /** Optional color override - falls back to palette */ + color?: string; + /** Optional fill override for patterns/gradients (e.g., "url(#patternId)") */ + fill?: string; +} + +/** Arc data computed by visx Pie */ +export interface PieArcData { + data: PieData; + index: number; + startAngle: number; + endAngle: number; + padAngle: number; + value: number; +} + +export interface PieContextValue { + // Data + data: PieData[]; + arcs: PieArcData[]; + + // Dimensions + size: number; + center: number; + outerRadius: number; + innerRadius: number; + padAngle: number; + cornerRadius: number; + + // Hover effect + hoverOffset: number; + + // Hover state + hoveredIndex: number | null; + setHoveredIndex: (index: number | null) => void; + + // Animation state + animationKey: number; + isLoaded: boolean; + enterTransition?: Transition; + enterStaggerScale: number; + + // Container ref for portals + containerRef: RefObject; + + // Computed values + totalValue: number; + + // Get color for a slice index + getColor: (index: number) => string; + + // Get fill for a slice index (supports patterns/gradients) + getFill: (index: number) => string; +} + +const PieContext = createContext(null); + +export function PieProvider({ + children, + value, +}: { + children: React.ReactNode; + value: PieContextValue; +}) { + return {children}; +} + +export function usePie(): PieContextValue { + const context = useContext(PieContext); + if (!context) { + throw new Error( + "usePie must be used within a PieProvider. " + + "Make sure your component is wrapped in .", + ); + } + return context; +} + +export default PieContext; diff --git a/ui/components/charts/pie-slice.tsx b/ui/components/charts/pie-slice.tsx new file mode 100644 index 00000000..5a5dad5f --- /dev/null +++ b/ui/components/charts/pie-slice.tsx @@ -0,0 +1,449 @@ +"use client"; + +import { arc as arcGenerator } from "@visx/shape"; +import { motion, useSpring, useTransform } from "motion/react"; +import { useEffect, useRef } from "react"; + +import { usePie } from "./pie-context"; +import { useMountProgress } from "./use-mount-progress"; + +// Helper to generate arc path using d3 arc generator +function generateArcPath( + innerRadius: number, + outerRadius: number, + startAngle: number, + endAngle: number, + cornerRadius: number, + padAngle: number, +): string { + const generator = arcGenerator({ + innerRadius, + outerRadius, + cornerRadius, + padAngle, + }); + return generator({ startAngle, endAngle } as unknown as null) || ""; +} + +// Calculate the translation offset for a slice to "pop out" along its radial axis +function getSliceOffset( + startAngle: number, + endAngle: number, + distance: number, +): { x: number; y: number } { + // Calculate the midpoint angle of the slice + const midAngle = (startAngle + endAngle) / 2; + // In d3-shape, 0 radians is at 12 o'clock, angles increase clockwise + // So the outward direction is: x = sin(angle), y = -cos(angle) + return { + x: Math.sin(midAngle) * distance, + y: -Math.cos(midAngle) * distance, + }; +} + +/** Hover effect types */ +export type PieSliceHoverEffect = "translate" | "grow" | "none"; + +export interface PieSliceProps { + /** Index of the slice in the data array */ + index: number; + /** Optional color override - falls back to data color or palette */ + color?: string; + /** Optional fill override for patterns/gradients (e.g., "url(#patternId)") */ + fill?: string; + /** Animate the slice on mount. Default: true */ + animate?: boolean; + /** Show glow effect on hover. Default: true */ + showGlow?: boolean; + /** + * Hover effect type. Default: "translate" + * - "translate": Slice moves outward along its radial axis + * - "grow": Slice extends its outer radius (gets longer) + * - "none": No hover animation + */ + hoverEffect?: PieSliceHoverEffect; + /** Distance in pixels for hover effect (translate distance or grow amount). Defaults to PieChart's hoverOffset */ + hoverOffset?: number; + /** Additional CSS class */ + className?: string; +} + +interface AnimatedSliceTranslateProps { + index: number; + innerRadius: number; + outerRadius: number; + startAngle: number; + endAngle: number; + cornerRadius: number; + padAngle: number; + fill: string; + color: string; + isHovered: boolean; + isFaded: boolean; + animationKey: number; + showGlow: boolean; + hoverOffset: number; +} + +function AnimatedSliceTranslate({ + index, + innerRadius, + outerRadius, + startAngle, + endAngle, + cornerRadius, + padAngle, + fill, + color, + isHovered, + isFaded, + animationKey, + showGlow, + hoverOffset, +}: AnimatedSliceTranslateProps) { + const { + enterTransition, + enterStaggerScale, + animationKey: pieAnimationKey, + } = usePie(); + const animationDelay = (0.1 + index * 0.08) * enterStaggerScale; + const mountProgress = useMountProgress( + enterTransition, + animationDelay, + pieAnimationKey, + ); + + const animatedPath = useTransform(mountProgress, (mount) => { + const currentEndAngle = startAngle + (endAngle - startAngle) * mount; + if (currentEndAngle <= startAngle + 0.01) { + return ""; + } + return generateArcPath( + innerRadius, + outerRadius, + startAngle, + currentEndAngle, + cornerRadius, + padAngle, + ); + }); + + const offset = getSliceOffset(startAngle, endAngle, hoverOffset); + const glowColor = color; + + return ( + + ); +} + +interface AnimatedSliceGrowProps { + index: number; + innerRadius: number; + outerRadius: number; + startAngle: number; + endAngle: number; + cornerRadius: number; + padAngle: number; + fill: string; + color: string; + isHovered: boolean; + isFaded: boolean; + animationKey: number; + showGlow: boolean; + hoverOffset: number; +} + +function AnimatedSliceGrow({ + index, + innerRadius, + outerRadius, + startAngle, + endAngle, + cornerRadius, + padAngle, + fill, + color, + isHovered, + isFaded, + animationKey, + showGlow, + hoverOffset, +}: AnimatedSliceGrowProps) { + const { + enterTransition, + enterStaggerScale, + animationKey: pieAnimationKey, + } = usePie(); + const animationDelay = (0.1 + index * 0.08) * enterStaggerScale; + const mountProgress = useMountProgress( + enterTransition, + animationDelay, + pieAnimationKey, + ); + + const growSpring = useSpring(outerRadius, { + stiffness: 400, + damping: 25, + }); + + useEffect(() => { + growSpring.set(isHovered ? outerRadius + hoverOffset : outerRadius); + }, [isHovered, hoverOffset, outerRadius, growSpring]); + + const animatedPath = useTransform( + [mountProgress, growSpring], + ([mount, currentOuterRadius]) => { + const currentEndAngle = + startAngle + (endAngle - startAngle) * (mount as number); + if (currentEndAngle <= startAngle + 0.01) { + return ""; + } + return generateArcPath( + innerRadius, + currentOuterRadius as number, + startAngle, + currentEndAngle, + cornerRadius, + padAngle, + ); + }, + ); + + const glowColor = color; + + return ( + + ); +} + +export function PieSlice({ + index, + color: colorProp, + fill: fillProp, + animate = true, + showGlow = true, + hoverEffect = "translate", + hoverOffset: hoverOffsetProp, +}: PieSliceProps) { + const { + arcs, + innerRadius, + outerRadius, + cornerRadius, + hoverOffset: contextHoverOffset, + hoveredIndex, + setHoveredIndex, + animationKey, + getColor, + getFill, + } = usePie(); + + // Use prop if provided, otherwise use context value + const hoverOffset = hoverOffsetProp ?? contextHoverOffset; + + // Track if initial mount animation is complete + const hasAnimated = useRef(false); + const sliceExpandDelay = index * 0.08; + + useEffect(() => { + if (animate && !hasAnimated.current) { + const timeout = setTimeout( + () => { + hasAnimated.current = true; + }, + (sliceExpandDelay + 0.5) * 1000, + ); + return () => clearTimeout(timeout); + } + return undefined; + }, [animate, sliceExpandDelay]); + + const arcData = arcs[index]; + if (!arcData) { + return null; + } + + const color = colorProp || getColor(index); + const fill = fillProp || getFill(index); + + const isHovered = hoveredIndex === index; + const isFaded = hoveredIndex !== null && hoveredIndex !== index; + + // Calculate values for non-animated/static paths + const offset = getSliceOffset( + arcData.startAngle, + arcData.endAngle, + hoverOffset, + ); + + // Generate the static hitbox path (always uses base outer radius) + const hitboxPath = generateArcPath( + innerRadius, + outerRadius, + arcData.startAngle, + arcData.endAngle, + cornerRadius, + arcData.padAngle, + ); + + // Generate the visible path for grow effect + const grownOuterRadius = isHovered ? outerRadius + hoverOffset : outerRadius; + const grownPath = generateArcPath( + innerRadius, + grownOuterRadius, + arcData.startAngle, + arcData.endAngle, + cornerRadius, + arcData.padAngle, + ); + + // Render animated slice based on effect type + const renderAnimatedSlice = () => { + if (hoverEffect === "grow") { + return ( + + ); + } + + // Default: translate effect (also covers "none" with hoverOffset=0) + return ( + + ); + }; + + // Render static (non-animated) slice + const renderStaticSlice = () => { + if (hoverEffect === "grow") { + return ( + + ); + } + + // Default: translate effect + const shouldTranslate = hoverEffect !== "none" && isHovered; + const translateX = shouldTranslate ? offset.x : 0; + const translateY = shouldTranslate ? offset.y : 0; + + return ( + + ); + }; + + return ( + + {/* Invisible hitbox - stays in place, handles hover events */} + {/* biome-ignore lint/a11y/noStaticElementInteractions: SVG path used as hover hitbox for visualization */} + setHoveredIndex(index)} + onMouseLeave={() => setHoveredIndex(null)} + /> + + {/* Visible slice - animates based on hover effect, no pointer events */} + {animate ? renderAnimatedSlice() : renderStaticSlice()} + + ); +} + +PieSlice.displayName = "PieSlice"; + +export default PieSlice; diff --git a/ui/components/charts/series-bar-layout.ts b/ui/components/charts/series-bar-layout.ts new file mode 100644 index 00000000..ed2249b8 --- /dev/null +++ b/ui/components/charts/series-bar-layout.ts @@ -0,0 +1,61 @@ +export function computeSeriesBarWidth(input: { + innerWidth: number; + dataLength: number; + columnWidth: number; + seriesCount: number; + composedBarSize?: number; + composedMaxBarSize?: number; + composedBarGap?: number; + stacked?: boolean; +}): number { + const { + innerWidth, + dataLength, + columnWidth, + seriesCount, + composedBarSize, + composedMaxBarSize, + composedBarGap = 4, + stacked = false, + } = input; + + const gap = composedBarGap; + const groupCount = stacked ? 1 : Math.max(1, seriesCount); + let slot = columnWidth; + if (slot <= 0) { + slot = dataLength < 2 ? innerWidth : innerWidth / (dataLength - 1); + } + + let width = + composedBarSize ?? + Math.min(slot * 0.88, composedMaxBarSize ?? Number.POSITIVE_INFINITY); + if (composedMaxBarSize != null) { + width = Math.min(width, composedMaxBarSize); + } + if (groupCount > 1) { + const maxGroup = slot * 0.92; + const needed = groupCount * width + (groupCount - 1) * gap; + if (needed > maxGroup && maxGroup > 0) { + width = Math.max(4, (maxGroup - (groupCount - 1) * gap) / groupCount); + } + } + + return Math.max(2, width); +} + +/** Half-width of the bar group at each x — used to pad reveal clips. */ +export function computeSeriesBarRevealClipPadding(input: { + barWidth: number; + seriesCount: number; + gap?: number; + stacked?: boolean; +}): number { + const { barWidth, seriesCount, gap = 4, stacked = false } = input; + + if (stacked || seriesCount <= 1) { + return Math.ceil(barWidth / 2); + } + + const groupWidth = seriesCount * barWidth + (seriesCount - 1) * gap; + return Math.ceil(groupWidth / 2); +} diff --git a/ui/components/charts/series-dash-tail-overlay.tsx b/ui/components/charts/series-dash-tail-overlay.tsx new file mode 100644 index 00000000..39bc5669 --- /dev/null +++ b/ui/components/charts/series-dash-tail-overlay.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { memo, useMemo } from "react"; + +import { DashTailStroke } from "./dash-tail-stroke"; +import { resolveDashStartX, resolveDashTailBounds } from "./path-stroke-utils"; + +interface SeriesDashTailOverlayProps { + dashFromIndex?: number; + dashArray: string; + data: Record[]; + pathD: string | null; + pathLength: number; + innerWidth: number; + innerHeight: number; + stroke: string; + strokeWidth: number; + xScale: (value: Date | number) => number | undefined; + xAccessor: (datum: Record) => Date | number; +} + +function SeriesDashTailOverlayImpl({ + dashFromIndex, + dashArray, + data, + pathD, + pathLength, + innerWidth, + innerHeight, + stroke, + strokeWidth, + xScale, + xAccessor, +}: SeriesDashTailOverlayProps) { + const hasDashTail = resolveDashTailBounds(dashFromIndex, data.length); + + const dashStartX = useMemo(() => { + if (!hasDashTail || dashFromIndex == null) { + return 0; + } + return resolveDashStartX(data, dashFromIndex, xScale, xAccessor); + }, [hasDashTail, dashFromIndex, data, xScale, xAccessor]); + + // Linear (index-based) approximation of the path length at `dashFromIndex`. + // The accurate version (`findPathLengthAtX` binary search via + // `getPointAtLength`) is exact but cost ~40 ms per series on a 365-point + // bezier — for charts with ~10 series that synchronously blocks the main + // thread for ~400 ms on the post-measurement re-render, swallowing the first + // second of the entrance animation. + // + // For evenly-spaced time-series data — the standard case — this is exact at + // flat regions of the curve and only differs by a pixel or two where the + // curve has steep y-variation, which is imperceptible at the dash boundary. + const dashStartLength = useMemo(() => { + if (!hasDashTail || dashFromIndex == null || pathLength <= 0) { + return 0; + } + return (dashFromIndex / Math.max(1, data.length - 1)) * pathLength; + }, [hasDashTail, dashFromIndex, data.length, pathLength]); + + if (!hasDashTail || dashFromIndex == null || pathLength <= 0) { + return null; + } + + return ( + + ); +} + +// All props originate from the chart's stable context slice (data, xScale, +// xAccessor, …) or are mount-stable strings (gradient `url(#…)` ids). Shallow +// compare lets us skip the path-length binary search on every cursor move. +export const SeriesDashTailOverlay = memo(SeriesDashTailOverlayImpl); diff --git a/ui/components/charts/series-highlight-layer.tsx b/ui/components/charts/series-highlight-layer.tsx new file mode 100644 index 00000000..79316765 --- /dev/null +++ b/ui/components/charts/series-highlight-layer.tsx @@ -0,0 +1,50 @@ +"use client"; + +import type { RefObject } from "react"; + +import { useChartStable } from "./chart-context"; +import { HighlightSegment } from "./highlight-segment"; +import { useHighlightSegment } from "./use-highlight-segment"; + +interface SeriesHighlightLayerProps { + /** Caller already gated `showHighlight && showLine`; this just routes through. */ + enabled: boolean; + height: number; + pathRef: RefObject; + stroke: string; + strokeWidth: number; +} + +/** + * Self-contained hover-highlight band over a series stroke. + * + * Owns the `useHighlightSegment` subscription (which reads both stable + hover + * context) so the parent / can stay on the stable slice. This + * component still re-renders on hover — that's the price of driving the + * highlight band — but it's a tiny leaf so the cost is bounded to itself. + */ +export function SeriesHighlightLayer({ + enabled, + height, + pathRef, + stroke, + strokeWidth, +}: SeriesHighlightLayerProps) { + const { isLoaded } = useChartStable(); + const { xSpring, widthSpring, isActive } = useHighlightSegment({ enabled }); + return ( + + ); +} + +SeriesHighlightLayer.displayName = "SeriesHighlightLayer"; + +export default SeriesHighlightLayer; diff --git a/ui/components/charts/series-hover-dim.tsx b/ui/components/charts/series-hover-dim.tsx new file mode 100644 index 00000000..36b132b7 --- /dev/null +++ b/ui/components/charts/series-hover-dim.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { motion } from "motion/react"; +import type { ReactNode } from "react"; + +import { useChartHover } from "./chart-context"; + +interface SeriesHoverDimProps { + /** Skip the dim entirely. */ + enabled?: boolean; + /** Opacity to fade to while the chart is being hovered. */ + dimOpacity?: number; + /** Tween duration in seconds. */ + durationSec?: number; + /** Stable chart visuals — area fill, stroke line, dashed tail, etc. */ + children: ReactNode; +} + +/** + * Wraps stable series visuals with a hover-driven opacity animation. + * + * The wrapper subscribes to chart hover state internally so the parent (Area / + * Line) can stay on the stable context slice. Children come in as a React prop: + * because the parent is not re-rendering on hover, the children element + * reference stays identical and React skips re-rendering them when this + * wrapper re-renders. That keeps expensive subtrees (`SeriesDashTailOverlay` + * and its `getPointAtLength` binary search) quiescent on cursor motion. + */ +export function SeriesHoverDim({ + enabled = true, + dimOpacity = 0.5, + durationSec = 0.4, + children, +}: SeriesHoverDimProps) { + const { tooltipData, selection } = useChartHover(); + const isHovering = tooltipData !== null || selection?.active === true; + const opacity = enabled && isHovering ? dimOpacity : 1; + return ( + + {children} + + ); +} + +SeriesHoverDim.displayName = "SeriesHoverDim"; + +export default SeriesHoverDim; diff --git a/ui/components/charts/series-markers.tsx b/ui/components/charts/series-markers.tsx new file mode 100644 index 00000000..e02b9ce7 --- /dev/null +++ b/ui/components/charts/series-markers.tsx @@ -0,0 +1,287 @@ +"use client"; + +import { type ReactNode, useCallback, useMemo } from "react"; + +import { clipRevealTransition } from "./animation"; +import { + defaultScatterColors, + useChartHover, + useChartStable, +} from "./chart-context"; +import { + getSeriesMarkerVisualExtent, + SeriesPointMarker, + type SeriesPointMarkerStyle, + StaticSeriesPointMarker, +} from "./series-point-marker"; + +export interface SeriesMarkersProps extends SeriesPointMarkerStyle { + dataKey: string; + /** Marker fill color. Defaults to series stroke or chart palette color. */ + fill?: string; + /** Whether to animate markers with clip reveal. Default: true */ + animate?: boolean; +} + +interface PointAt { + index: number; + cx: number; + cy: number; + revealDelay: number; +} + +interface MarkerStyle { + fill: string; + stroke: string; + strokeWidth: number; + ringGap: number; + outlineWidth: number; + outlineColor?: string; + radius: number; +} + +export function SeriesMarkers({ + dataKey, + fill, + stroke, + strokeWidth = 2, + ringGap = 2, + outlineWidth = 0, + outlineColor, + radius = 5, + animate = true, + fadeOnHover = true, + inactiveOpacity = 0.5, + inactiveBlur = 2, + enterBlur = 2, + showActiveHighlight = true, +}: SeriesMarkersProps) { + // Stable slice only. Hover-driven dim + active-highlight live in the inner + // / components, so + // mouse motion does not re-render the full point grid. + const { + data, + xScale, + yScale, + innerWidth, + enterTransition, + animationDuration, + revealEpoch, + isLoaded, + xAccessor, + lines, + } = useChartStable(); + + const seriesIndex = useMemo(() => { + const index = lines.findIndex((line) => line.dataKey === dataKey); + return index >= 0 ? index : 0; + }, [lines, dataKey]); + + const seriesConfig = lines[seriesIndex]; + const seriesColor = + defaultScatterColors[seriesIndex % defaultScatterColors.length] ?? + defaultScatterColors[0]; + + const resolvedFill = fill ?? seriesConfig?.stroke ?? seriesColor; + const resolvedStroke = stroke ?? resolvedFill; + + const visualExtent = useMemo( + () => + getSeriesMarkerVisualExtent({ + radius, + strokeWidth, + ringGap, + outlineWidth, + showActiveHighlight, + }), + [radius, strokeWidth, ringGap, outlineWidth, showActiveHighlight], + ); + + const revealDurationSec = + clipRevealTransition(enterTransition).duration ?? animationDuration / 1000; + const enterDuration = 0.5; + const isRevealing = animate && !isLoaded; + + const getY = useCallback( + (d: Record) => { + const value = d[dataKey]; + return typeof value === "number" ? (yScale(value) ?? 0) : null; + }, + [dataKey, yScale], + ); + + const points = useMemo( + () => + data.flatMap((d, index) => { + const cy = getY(d); + if (cy === null) { + return []; + } + const cx = xScale(xAccessor(d)) ?? 0; + const leadingEdge = Math.max(0, cx - visualExtent); + const revealDelay = + innerWidth > 0 && isRevealing + ? (leadingEdge / innerWidth) * revealDurationSec + : 0; + + return [{ index, cx, cy, revealDelay }]; + }), + [ + data, + getY, + xScale, + xAccessor, + innerWidth, + isRevealing, + revealDurationSec, + visualExtent, + ], + ); + + // Memo so the inner sees a stable prop and + // can be cheaply re-rendered on hover without re-creating the spread. + const markerStyle = useMemo( + () => ({ + fill: resolvedFill, + stroke: resolvedStroke, + strokeWidth, + ringGap, + outlineWidth, + outlineColor, + radius, + }), + [ + resolvedFill, + resolvedStroke, + strokeWidth, + ringGap, + outlineWidth, + outlineColor, + radius, + ], + ); + + if (isRevealing) { + return ( + + {points.map((point) => ( + + ))} + + ); + } + + // Stable base layer — its children come from the parent and stay + // referentially identical when the dim wrapper re-renders for hover. + const baseMarkers = points.map((point) => ( + + )); + const activeScale = showActiveHighlight ? 1.35 : 1; + + return ( + + + {baseMarkers} + + + + ); +} + +SeriesMarkers.displayName = "SeriesMarkers"; + +interface SeriesMarkersDimWrapperProps { + enabled: boolean; + inactiveOpacity: number; + inactiveBlur: number; + children: ReactNode; +} + +/** + * Wraps the stable point grid with hover-driven opacity + blur. Subscribes to + * hover internally so the grid (passed as `children`) keeps a stable reference + * and React skips reconciling it when this wrapper re-renders. + */ +function SeriesMarkersDimWrapper({ + enabled, + inactiveOpacity, + inactiveBlur, + children, +}: SeriesMarkersDimWrapperProps) { + const { tooltipData } = useChartHover(); + const dimBase = enabled && tooltipData !== null; + return ( + 0 ? `blur(${inactiveBlur}px)` : "none", + }} + > + {children} + + ); +} + +interface SeriesMarkersActiveHighlightProps { + enabled: boolean; + points: PointAt[]; + markerStyle: MarkerStyle; + activeScale: number; +} + +/** + * Renders the scaled "active" marker on top of the base grid. Subscribes to + * hover internally; the parent doesn't re-render on cursor motion. + */ +function SeriesMarkersActiveHighlight({ + enabled, + points, + markerStyle, + activeScale, +}: SeriesMarkersActiveHighlightProps) { + const { tooltipData } = useChartHover(); + if (!enabled || tooltipData === null) { + return null; + } + const activePoint = points.find((point) => point.index === tooltipData.index); + if (!activePoint) { + return null; + } + return ( + + ); +} + +export default SeriesMarkers; diff --git a/ui/components/charts/series-point-marker.tsx b/ui/components/charts/series-point-marker.tsx new file mode 100644 index 00000000..8c2aa366 --- /dev/null +++ b/ui/components/charts/series-point-marker.tsx @@ -0,0 +1,210 @@ +"use client"; + +import type { Variants } from "motion/react"; +import { motion } from "motion/react"; +import { memo } from "react"; + +import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation"; + +export interface SeriesPointMarkerStyle { + /** Fill color for the inner circle */ + fill?: string; + /** Outer ring stroke color. Default: same as `fill` */ + stroke?: string; + /** Outer ring stroke width in px. Default: 2. Set to 0 to disable. */ + strokeWidth?: number; + /** Gap between the inner fill and outer ring in px. Default: 2 */ + ringGap?: number; + /** Optional outer outline beyond the ring. Default: 0 */ + outlineWidth?: number; + /** Outer outline color. Default: same as `stroke` */ + outlineColor?: string; + /** Point radius in px. Default: 5 */ + radius?: number; + /** Dim non-active points while hovering. Default: true */ + fadeOnHover?: boolean; + /** Opacity for non-hovered points when `fadeOnHover` is true. Default: 0.5 */ + inactiveOpacity?: number; + /** + * Blur in px for non-hovered points when `fadeOnHover` is true. + * Applied once on the dimmed layer (not per dot) for performance. Default: 2 + */ + inactiveBlur?: number; + /** Initial blur in px during enter animation. Default: 2 */ + enterBlur?: number; + /** Enlarge the active point while hovering. Default: true */ + showActiveHighlight?: boolean; +} + +interface MarkerCirclesProps { + fill?: string; + stroke?: string; + strokeWidth: number; + ringGap: number; + outlineWidth: number; + outlineColor?: string; + radius: number; +} + +function MarkerCircles({ + fill, + stroke, + strokeWidth, + ringGap, + outlineWidth, + outlineColor, + radius, +}: MarkerCirclesProps) { + const resolvedStroke = stroke ?? fill ?? "currentColor"; + const resolvedOutlineColor = outlineColor ?? resolvedStroke; + const ringOuter = strokeWidth > 0 ? radius + ringGap + strokeWidth : radius; + const outlineRadius = outlineWidth > 0 ? ringOuter + outlineWidth / 2 : 0; + + return ( + <> + {outlineWidth > 0 ? ( + + ) : null} + + {strokeWidth > 0 ? ( + + ) : null} + + ); +} + +export interface StaticSeriesPointMarkerProps extends SeriesPointMarkerStyle { + cx: number; + cy: number; + scale?: number; +} + +export const StaticSeriesPointMarker = memo(function StaticSeriesPointMarker({ + cx, + cy, + scale = 1, + fill, + stroke, + strokeWidth = 2, + ringGap = 2, + outlineWidth = 0, + outlineColor, + radius = 5, +}: StaticSeriesPointMarkerProps) { + return ( + + + + ); +}); + +export interface SeriesPointMarkerProps extends SeriesPointMarkerStyle { + dataKey: string; + index: number; + cx: number; + cy: number; + revealDelay: number; + revealEpoch: number; + enterDuration: number; +} + +/** Motion enter marker — used only while the chart reveal is running. */ +export function SeriesPointMarker({ + dataKey, + index, + cx, + cy, + enterBlur = 2, + revealDelay, + revealEpoch, + enterDuration, + fill, + stroke, + strokeWidth = 2, + ringGap = 2, + outlineWidth = 0, + outlineColor, + radius = 5, +}: SeriesPointMarkerProps) { + const variants: Variants = { + hidden: { + opacity: 0, + filter: `blur(${enterBlur}px)`, + scale: 1, + }, + visible: { + opacity: 1, + filter: "blur(0px)", + scale: 1, + transition: { + delay: revealDelay, + duration: enterDuration, + ease: DEFAULT_CHART_ENTER_TRANSITION.ease, + }, + }, + }; + + return ( + + + + + + ); +} + +export function getSeriesMarkerVisualExtent( + style: Pick< + SeriesPointMarkerStyle, + | "radius" + | "strokeWidth" + | "ringGap" + | "outlineWidth" + | "showActiveHighlight" + >, +): number { + const radius = style.radius ?? 5; + const strokeWidth = style.strokeWidth ?? 2; + const ringGap = style.ringGap ?? 2; + const outlineWidth = style.outlineWidth ?? 0; + const showActiveHighlight = style.showActiveHighlight ?? true; + const ring = strokeWidth > 0 ? ringGap + strokeWidth : 0; + const outline = outlineWidth > 0 ? outlineWidth : 0; + const highlightPad = showActiveHighlight ? radius * 0.35 : 0; + return radius + ring + outline + highlightPad + 2; +} diff --git a/ui/components/charts/static-chart-preview-context.tsx b/ui/components/charts/static-chart-preview-context.tsx new file mode 100644 index 00000000..0930ad19 --- /dev/null +++ b/ui/components/charts/static-chart-preview-context.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { createContext, type ReactNode, useContext } from "react"; + +const StaticChartPreviewContext = createContext(false); + +/** Disables cartesian reveal clip-path for static docs previews. */ +export function StaticChartPreviewProvider({ + children, +}: { + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useStaticChartPreview() { + return useContext(StaticChartPreviewContext); +} diff --git a/ui/components/charts/time-series-chart-shell.tsx b/ui/components/charts/time-series-chart-shell.tsx new file mode 100644 index 00000000..5cd8c73b --- /dev/null +++ b/ui/components/charts/time-series-chart-shell.tsx @@ -0,0 +1,454 @@ +"use client"; + +import { scaleLinear, scaleTime } from "@visx/scale"; +import { bisector, extent } from "d3-array"; +import type { Transition } from "motion/react"; +import { + Children, + cloneElement, + isValidElement, + memo, + type ReactElement, + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; + +import { DEFAULT_ANIMATION_EASING } from "./animation"; +import { ChartProvider, type LineConfig, type Margin } from "./chart-context"; +import { isGradientDefComponent, isPatternDefComponent } from "./chart-defs"; +import { shortDateFmt } from "./chart-formatters"; +import { ChartRevealClip } from "./chart-reveal-clip"; +import { + decimateTimeSeries, + maxRenderPointsForWidth, +} from "./decimate-time-series"; +import { + computeSeriesBarRevealClipPadding, + computeSeriesBarWidth, +} from "./series-bar-layout"; +import { useStaticChartPreview } from "./static-chart-preview-context"; +import { useChartInteraction } from "./use-chart-interaction"; + +function collectNumericExtents( + data: Record[], + dataKeys: string[], +) { + let minValue = Number.POSITIVE_INFINITY; + let maxValue = Number.NEGATIVE_INFINITY; + + for (const d of data) { + for (const key of dataKeys) { + const value = d[key]; + if (typeof value === "number") { + if (value < minValue) { + minValue = value; + } + if (value > maxValue) { + maxValue = value; + } + } + } + } + + if (minValue === Number.POSITIVE_INFINITY) { + return { minValue: 0, maxValue: 100 }; + } + + return { minValue, maxValue }; +} + +function resolveTimeSeriesYDomain( + data: Record[], + dataKeys: string[], + yScaleDomainMax: number | undefined, +): [number, number] { + if (yScaleDomainMax != null && yScaleDomainMax > 0) { + return [0, yScaleDomainMax * 1.1]; + } + + const { minValue, maxValue } = collectNumericExtents(data, dataKeys); + + if (minValue >= 0) { + const top = maxValue <= 0 ? 100 : maxValue * 1.1; + return [0, top]; + } + + const padding = (maxValue - minValue) * 0.05 || 1; + return [minValue - padding, maxValue + padding]; +} + +/** Markers render after the interaction overlay so they stay clickable. */ +export function isPostOverlayComponent(child: ReactElement): boolean { + const childType = child.type as { + displayName?: string; + name?: string; + __isChartMarkers?: boolean; + }; + + if (childType.__isChartMarkers) { + return true; + } + + const componentName = + typeof child.type === "function" + ? childType.displayName || childType.name || "" + : ""; + + return componentName === "ChartMarkers" || componentName === "MarkerGroup"; +} + +function ensureChildKey(child: ReactElement, index: number): ReactElement { + if (child.key != null) { + return child; + } + return cloneElement(child, { key: `chart-child-${index}` }); +} + +export interface TimeSeriesChartInnerProps { + width: number; + height: number; + data: Record[]; + xDataKey: string; + margin: Margin; + animationDuration: number; + animationEasing?: string; + enterTransition?: Transition; + /** Signature of motion URL state — triggers reveal replay when it changes. */ + revealSignature?: string; + children: ReactNode; + containerRef: React.RefObject; + /** Series keys driving y-domain and tooltip (Line / Area / SeriesBar configs). */ + lines: LineConfig[]; + /** SVG clipPath id for grow animation. */ + clipPathId: string; + /** Optional ComposedChart bar layout (forwarded into context). */ + composedBarDataKeys?: string[]; + composedBarSize?: number; + composedMaxBarSize?: number; + composedBarGap?: number; + composedStacked?: boolean; + composedStackOffsets?: Map>; + composedStackGap?: number; + /** When set, drives the y-axis max instead of scanning `lines` (e.g. stacked bar totals). */ + yScaleDomainMax?: number; +} + +export function TimeSeriesChartInner(props: TimeSeriesChartInnerProps) { + const { width, height } = props; + if (width < 10 || height < 10) { + return null; + } + return ; +} + +const TimeSeriesChartCore = memo(function TimeSeriesChartCore({ + width, + height, + data, + xDataKey, + margin, + animationDuration, + animationEasing = DEFAULT_ANIMATION_EASING, + enterTransition, + revealSignature = "", + children, + containerRef, + lines, + clipPathId, + composedBarDataKeys, + composedBarSize, + composedMaxBarSize, + composedBarGap, + composedStacked, + composedStackOffsets, + composedStackGap, + yScaleDomainMax, +}: TimeSeriesChartInnerProps) { + const staticPreview = useStaticChartPreview(); + const [isLoaded, setIsLoaded] = useState(staticPreview); + const [revealEpoch, setRevealEpoch] = useState(0); + + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + const xAccessor = useCallback( + (d: Record): Date => { + const value = d[xDataKey]; + return value instanceof Date ? value : new Date(value as string | number); + }, + [xDataKey], + ); + + const bisectDate = useMemo(() => { + const dateBisector = bisector, Date>((d) => + xAccessor(d), + ); + return (rows: Record[], date: Date, lo: number) => + dateBisector.left(rows, date, lo); + }, [xAccessor]); + + const xScale = useMemo(() => { + const timeExtent = extent(data, (d) => xAccessor(d).getTime()); + const minTime = timeExtent[0] ?? 0; + const maxTime = timeExtent[1] ?? minTime; + + return scaleTime({ + range: [0, innerWidth], + domain: [minTime, maxTime], + }); + }, [innerWidth, data, xAccessor]); + + const renderData = useMemo(() => { + const valueKeys = lines.map((line) => line.dataKey); + return decimateTimeSeries( + data, + maxRenderPointsForWidth(innerWidth), + valueKeys, + ); + }, [data, innerWidth, lines]); + + const columnWidth = useMemo(() => { + if (data.length < 2) { + return 0; + } + return innerWidth / (data.length - 1); + }, [innerWidth, data.length]); + + const yScale = useMemo(() => { + const dataKeys = lines.map((line) => line.dataKey); + const domain = resolveTimeSeriesYDomain(data, dataKeys, yScaleDomainMax); + + return scaleLinear({ + range: [innerHeight, 0], + domain, + nice: true, + }); + }, [innerHeight, data, lines, yScaleDomainMax]); + + const dateLabels = useMemo( + () => data.map((d) => shortDateFmt.format(xAccessor(d))), + [data, xAccessor], + ); + + // biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature + useEffect(() => { + if (staticPreview) { + setIsLoaded(true); + return; + } + + setRevealEpoch((n) => n + 1); + setIsLoaded(false); + const timer = setTimeout(() => { + setIsLoaded(true); + }, animationDuration); + return () => clearTimeout(timer); + }, [animationDuration, revealSignature, staticPreview]); + + const canInteract = isLoaded; + + const { + tooltipData, + setTooltipData, + selection, + clearSelection, + interactionHandlers, + interactionStyle, + } = useChartInteraction({ + xScale, + yScale, + data, + lines, + margin, + xAccessor, + bisectDate, + canInteract, + }); + + const defsChildren: ReactElement[] = []; + const preOverlayChildren: ReactElement[] = []; + const postOverlayChildren: ReactElement[] = []; + + Children.forEach(children, (child, index) => { + if (!isValidElement(child)) { + return; + } + + const keyedChild = ensureChildKey(child, index); + + if (isGradientDefComponent(keyedChild)) { + defsChildren.push(keyedChild); + } else if (isPatternDefComponent(keyedChild)) { + // Keep pattern defs in the plot (same as main) — hoisting breaks url(#id) fills. + preOverlayChildren.push(keyedChild); + } else if (isPostOverlayComponent(keyedChild)) { + postOverlayChildren.push(keyedChild); + } else { + preOverlayChildren.push(keyedChild); + } + }); + + const contextValue = useMemo( + () => ({ + data, + renderData, + xScale, + yScale, + width, + height, + innerWidth, + innerHeight, + margin, + columnWidth, + tooltipData, + setTooltipData, + containerRef, + lines, + isLoaded, + animationDuration, + animationEasing, + enterTransition, + revealEpoch, + xAccessor, + dateLabels, + selection, + clearSelection, + composedBarDataKeys, + composedBarSize, + composedMaxBarSize, + composedBarGap, + composedStacked, + composedStackOffsets, + composedStackGap, + }), + [ + data, + renderData, + xScale, + yScale, + width, + height, + innerWidth, + innerHeight, + margin, + columnWidth, + tooltipData, + setTooltipData, + containerRef, + lines, + isLoaded, + animationDuration, + animationEasing, + enterTransition, + revealEpoch, + xAccessor, + dateLabels, + selection, + clearSelection, + composedBarDataKeys, + composedBarSize, + composedMaxBarSize, + composedBarGap, + composedStacked, + composedStackOffsets, + composedStackGap, + ], + ); + + // Single shared reveal clip for every series. Replaces the per- / + // per- `` motion.rects: one motion-driven attribute + // animation instead of N, with all series referencing the same ``. + // The wipe semantics (left-to-right unveil of static path geometry) are + // identical to the previous per-series clips. + // animationDuration === 0 truly disables the reveal (no clipPath wrapper), + // so consumers can opt out without having to also pass enterTransition. + const showReveal = + !staticPreview && + renderData.length > 1 && + innerWidth > 0 && + animationDuration > 0; + // If the consumer didn't pass an explicit enterTransition, derive one from + // animationDuration so clipRevealTransition picks up the override instead + // of falling back to its 1100ms default. + const effectiveEnterTransition: Transition = enterTransition ?? { + type: "tween", + duration: animationDuration / 1000, + }; + + const revealClipPadding = useMemo(() => { + if (!composedBarDataKeys?.length) { + return 0; + } + const barWidth = computeSeriesBarWidth({ + innerWidth, + dataLength: data.length, + columnWidth, + seriesCount: composedBarDataKeys.length, + composedBarSize, + composedMaxBarSize, + composedBarGap, + stacked: composedStacked, + }); + return computeSeriesBarRevealClipPadding({ + barWidth, + seriesCount: composedBarDataKeys.length, + gap: composedBarGap, + stacked: composedStacked, + }); + }, [ + columnWidth, + composedBarDataKeys, + composedBarGap, + composedBarSize, + composedMaxBarSize, + composedStacked, + data.length, + innerWidth, + ]); + + return ( + + + + ); +}); diff --git a/ui/components/charts/tooltip/chart-tooltip.tsx b/ui/components/charts/tooltip/chart-tooltip.tsx new file mode 100644 index 00000000..6090eaf9 --- /dev/null +++ b/ui/components/charts/tooltip/chart-tooltip.tsx @@ -0,0 +1,332 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import { memo, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; + +import { type SpringConfig, useChartConfig } from "../chart-config-context"; +import { + chartCssVars, + type LineConfig, + useChart, + useChartStable, +} from "../chart-context"; +import { weekdayDateFmt } from "../chart-formatters"; +import { DateTicker } from "./date-ticker"; +import { TooltipBox } from "./tooltip-box"; +import { TooltipContent, type TooltipRow } from "./tooltip-content"; +import { TooltipDot } from "./tooltip-dot"; +import { TooltipIndicator } from "./tooltip-indicator"; + +export interface ChartTooltipProps { + /** Whether to show the date pill at bottom. Default: true */ + showDatePill?: boolean; + /** Whether to show the vertical crosshair line. Default: true */ + showCrosshair?: boolean; + /** Whether to show dots on the lines. Default: true */ + showDots?: boolean; + /** + * Color for the crosshair/indicator line. When a function, receives the hovered point + * (e.g. for candlestick: match candle color from close vs open). Default: --chart-crosshair. + */ + indicatorColor?: string | ((point: Record) => string); + /** Custom content renderer for the tooltip box */ + content?: (props: { + point: Record; + index: number; + }) => React.ReactNode; + /** Custom row renderer - return array of TooltipRow */ + rows?: (point: Record) => TooltipRow[]; + /** + * Override tooltip dot fill. When omitted and `rows` is set, dot colors match row colors. + * When a function, receives the hovered point and line config. + */ + dotColor?: + | string + | ((point: Record, line: LineConfig) => string); + /** Additional content to show below rows (e.g., markers) */ + children?: React.ReactNode; + /** Custom class name */ + className?: string; + /** Per-chart override for the crosshair / dot / date-pill spring. */ + springConfig?: SpringConfig; + /** Per-chart override for the floating-panel spring. */ + boxSpringConfig?: SpringConfig; +} + +interface ChartTooltipInnerProps extends ChartTooltipProps { + container: HTMLElement; +} + +const ChartTooltipInner = memo(function ChartTooltipInner({ + showDatePill = true, + showCrosshair = true, + showDots = true, + indicatorColor: indicatorColorProp, + content, + rows: rowsRenderer, + dotColor: dotColorProp, + children, + className = "", + container, + springConfig, + boxSpringConfig, +}: ChartTooltipInnerProps) { + const { + tooltipData, + width, + height, + innerHeight, + margin, + columnWidth, + lines, + xAccessor, + dateLabels, + containerRef, + orientation, + barXAccessor, + } = useChart(); + + const isHorizontal = orientation === "horizontal"; + const discreteInteraction = dateLabels.length > 60; + + const visible = tooltipData !== null; + const x = tooltipData?.x ?? 0; + const xWithMargin = x + margin.left; + + // For horizontal charts, get the y position from the first line's yPosition (center of bar) + const firstLineDataKey = lines[0]?.dataKey; + const firstLineY = firstLineDataKey + ? (tooltipData?.yPositions[firstLineDataKey] ?? 0) + : 0; + const yWithMargin = firstLineY + margin.top; + + const tooltipRows = useMemo(() => { + if (!tooltipData) { + return []; + } + + if (rowsRenderer) { + return rowsRenderer(tooltipData.point); + } + + // Default: generate rows from registered lines + return lines.map((line) => ({ + color: line.stroke, + label: line.dataKey, + value: (tooltipData.point[line.dataKey] as number) ?? 0, + })); + }, [tooltipData, lines, rowsRenderer]); + + const resolveDotColor = useMemo(() => { + return (line: LineConfig, index: number): string => { + if (rowsRenderer && tooltipRows[index]?.color) { + return tooltipRows[index].color; + } + if (dotColorProp != null) { + if (typeof dotColorProp === "function" && tooltipData) { + return dotColorProp(tooltipData.point, line); + } + if (typeof dotColorProp === "string") { + return dotColorProp; + } + } + return line.stroke; + }; + }, [dotColorProp, rowsRenderer, tooltipData, tooltipRows]); + + // Resolve indicator color (static or from hovered point) + const indicatorColor = useMemo(() => { + if (indicatorColorProp == null) { + return chartCssVars.crosshair; + } + if (typeof indicatorColorProp === "function") { + return tooltipData + ? indicatorColorProp(tooltipData.point) + : chartCssVars.crosshair; + } + return indicatorColorProp; + }, [indicatorColorProp, tooltipData]); + + // Title from date or category + const title = useMemo(() => { + if (!tooltipData) { + return undefined; + } + // For bar charts (horizontal or vertical), use the category name + if (barXAccessor) { + return barXAccessor(tooltipData.point); + } + // For line/area charts, use the date + return weekdayDateFmt.format(xAccessor(tooltipData.point)); + }, [tooltipData, barXAccessor, xAccessor]); + + const tooltipContent = ( + <> + {/* Crosshair indicator - rendered as SVG overlay */} + {showCrosshair && ( + + )} + + {/* Dots on bars/lines - show for vertical charts only */} + {showDots && visible && !isHorizontal && ( + + )} + + {/* Tooltip Box */} + + {content && tooltipData + ? content({ + point: tooltipData.point, + index: tooltipData.index, + }) + : !content && ( + + {children} + + )} + + + {/* Date/Category Ticker - only show for vertical charts */} + + + ); + + return createPortal(tooltipContent, container); +}); + +export function ChartTooltip(props: ChartTooltipProps) { + const { containerRef } = useChartStable(); + const [mounted, setMounted] = useState(false); + + // Only render portals on client side after mount + useEffect(() => { + setMounted(true); + }, []); + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + return ; +} + +ChartTooltip.displayName = "ChartTooltip"; + +interface DatePillTrackerProps { + enabled: boolean; + visible: boolean; + labels: string[]; + currentIndex: number; + xWithMargin: number; + discreteInteraction: boolean; + springConfig?: SpringConfig; +} + +// Inner-only-on-visible so `useSpring` initializes at the real cursor x +// instead of `margin.left` on first hover. +function DatePillTracker(props: DatePillTrackerProps) { + if (!(props.enabled && props.visible && props.labels.length > 0)) { + return null; + } + return ; +} + +function DatePillTrackerInner({ + labels, + currentIndex, + xWithMargin, + discreteInteraction, + springConfig, + visible, +}: DatePillTrackerProps) { + const { tooltipSpring } = useChartConfig(); + const effectiveSpring = springConfig ?? tooltipSpring; + const animatedX = useSpring(xWithMargin, effectiveSpring); + + if (!discreteInteraction) { + animatedX.set(xWithMargin); + } + + // biome-ignore lint/correctness/useExhaustiveDependencies: we need to jump the animatedX when the visible prop changes + useEffect(() => { + animatedX.set(xWithMargin); + }, [animatedX, visible, xWithMargin]); + + return ( + + + + ); +} + +export default ChartTooltip; diff --git a/ui/components/charts/tooltip/date-ticker.tsx b/ui/components/charts/tooltip/date-ticker.tsx new file mode 100644 index 00000000..d4a99e06 --- /dev/null +++ b/ui/components/charts/tooltip/date-ticker.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import { memo, useMemo, useRef } from "react"; + +const TICKER_ITEM_HEIGHT = 24; +/** Full scroll stacks are skipped above this count — single label + instant updates. */ +const COMPACT_TICKER_THRESHOLD = 60; + +export interface DateTickerProps { + currentIndex: number; + labels: string[]; + visible: boolean; +} + +const DateTickerCompact = memo(function DateTickerCompact({ + currentIndex, + labels, +}: Omit) { + const label = labels[currentIndex] ?? labels[0] ?? ""; + + return ( +
+
+ {label} +
+
+ ); +}); + +const DateTickerInner = memo(function DateTickerInner({ + currentIndex, + labels, +}: Omit) { + // Parse labels into month and day parts + const parsedLabels = useMemo(() => { + return labels.map((label, index) => { + const parts = label.split(" "); + const month = parts[0] || ""; + const day = parts[1] || ""; + return { month, day, full: label, key: `${label}::${index}` }; + }); + }, [labels]); + + // Month segments: one entry per consecutive run (Jan → Feb → …), keyed by start index + const monthSegments = useMemo(() => { + const segments: { month: string; key: string; startIndex: number }[] = []; + + parsedLabels.forEach((label, index) => { + const prev = segments.at(-1); + if (!prev || prev.month !== label.month) { + segments.push({ + month: label.month, + key: `${label.month}-${index}`, + startIndex: index, + }); + } + }); + + return segments; + }, [parsedLabels]); + + // Index into monthSegments for the current data point + const currentMonthIndex = useMemo(() => { + if (currentIndex < 0 || currentIndex >= parsedLabels.length) { + return 0; + } + for (let i = monthSegments.length - 1; i >= 0; i--) { + const segment = monthSegments[i]; + if (segment && segment.startIndex <= currentIndex) { + return i; + } + } + return 0; + }, [currentIndex, parsedLabels.length, monthSegments]); + + // Track previous month index + const prevMonthIndexRef = useRef(-1); + + // Animated Y offsets + const dayY = useSpring(0, { stiffness: 400, damping: 35 }); + const monthY = useSpring(0, { stiffness: 400, damping: 35 }); + + dayY.set(-currentIndex * TICKER_ITEM_HEIGHT); + + if (currentMonthIndex >= 0) { + const isFirstRender = prevMonthIndexRef.current === -1; + const monthChanged = prevMonthIndexRef.current !== currentMonthIndex; + if (isFirstRender || monthChanged) { + monthY.set(-currentMonthIndex * TICKER_ITEM_HEIGHT); + prevMonthIndexRef.current = currentMonthIndex; + } + } + + return ( +
+
+
+ {/* Month stack */} +
+ + {monthSegments.map((segment) => ( +
+ + {segment.month} + +
+ ))} +
+
+ + {/* Day stack */} +
+ + {parsedLabels.map((label) => ( +
+ + {label.day} + +
+ ))} +
+
+
+
+
+ ); +}); + +export function DateTicker({ currentIndex, labels, visible }: DateTickerProps) { + if (!visible || labels.length === 0) { + return null; + } + + if (labels.length > COMPACT_TICKER_THRESHOLD) { + return ; + } + + return ; +} + +DateTicker.displayName = "DateTicker"; + +export default DateTicker; diff --git a/ui/components/charts/tooltip/index.ts b/ui/components/charts/tooltip/index.ts new file mode 100644 index 00000000..5b4dafcf --- /dev/null +++ b/ui/components/charts/tooltip/index.ts @@ -0,0 +1,14 @@ +export { ChartTooltip, type ChartTooltipProps } from "./chart-tooltip"; +export { DateTicker, type DateTickerProps } from "./date-ticker"; +export { TooltipBox, type TooltipBoxProps } from "./tooltip-box"; +export { + TooltipContent, + type TooltipContentProps, + type TooltipRow, +} from "./tooltip-content"; +export { TooltipDot, type TooltipDotProps } from "./tooltip-dot"; +export { + type IndicatorWidth, + TooltipIndicator, + type TooltipIndicatorProps, +} from "./tooltip-indicator"; diff --git a/ui/components/charts/tooltip/tooltip-box.tsx b/ui/components/charts/tooltip/tooltip-box.tsx new file mode 100644 index 00000000..0327827a --- /dev/null +++ b/ui/components/charts/tooltip/tooltip-box.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import type { RefObject } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { cn } from "@/ui/lib/utils"; + +import { type SpringConfig, useChartConfig } from "../chart-config-context"; + +export interface TooltipBoxProps { + /** X position in pixels (relative to container) */ + x: number; + /** Y position in pixels (relative to container) */ + y: number; + /** Whether the tooltip is visible */ + visible: boolean; + /** Container ref for portal rendering */ + containerRef: RefObject; + /** Container width for flip detection */ + containerWidth: number; + /** Container height for bounds clamping */ + containerHeight: number; + /** Offset from the target position */ + offset?: number; + /** Custom class name */ + className?: string; + /** Tooltip content */ + children: React.ReactNode; + /** Override left position (bypasses internal calculation) */ + left?: number | ReturnType; + /** Override top position (bypasses internal calculation) */ + top?: number | ReturnType; + /** Force flip direction (for custom positioning) */ + flipped?: boolean; + /** Per-chart override; falls back to `ChartConfigProvider.tooltipBoxSpring`. */ + springConfig?: SpringConfig; +} + +// Inner-only-on-visible so `useSpring` initializes at the cursor's actual x/y +// instead of (0, 0) on first hover. +export function TooltipBox(props: TooltipBoxProps) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const container = props.containerRef.current; + if (!(mounted && container)) { + return null; + } + if (!props.visible) { + return null; + } + return ; +} + +function TooltipBoxInner({ + x, + y, + containerWidth, + containerHeight, + offset = 16, + className = "", + children, + left: leftOverride, + top: topOverride, + flipped: flippedOverride, + springConfig, + container, +}: Omit & { + container: HTMLElement; +}) { + const { tooltipBoxSpring } = useChartConfig(); + const effectiveSpring = springConfig ?? tooltipBoxSpring; + + const tooltipRef = useRef(null); + const tooltipWidthRef = useRef(180); + const tooltipHeightRef = useRef(80); + + const tw = tooltipWidthRef.current; + const th = tooltipHeightRef.current; + const shouldFlipX = x + tw + offset > containerWidth; + const targetX = shouldFlipX ? x - offset - tw : x + offset; + const targetY = Math.max( + offset, + Math.min(y - th / 2, containerHeight - th - offset), + ); + + const animatedLeft = useSpring(targetX, effectiveSpring); + const animatedTop = useSpring(targetY, effectiveSpring); + + if (leftOverride === undefined) { + animatedLeft.set(targetX); + } + if (topOverride === undefined) { + animatedTop.set(targetY); + } + + useLayoutEffect(() => { + if (!tooltipRef.current) { + return; + } + const el = tooltipRef.current; + const w = el.offsetWidth; + const h = el.offsetHeight; + if (w > 0) { + tooltipWidthRef.current = w; + } + if (h > 0) { + tooltipHeightRef.current = h; + } + const w2 = tooltipWidthRef.current; + const h2 = tooltipHeightRef.current; + const flip = x + w2 + offset > containerWidth; + const tx = flip ? x - offset - w2 : x + offset; + const ty = Math.max( + offset, + Math.min(y - h2 / 2, containerHeight - h2 - offset), + ); + if (leftOverride === undefined) { + animatedLeft.set(tx); + } + if (topOverride === undefined) { + animatedTop.set(ty); + } + }, [ + x, + y, + containerWidth, + containerHeight, + offset, + leftOverride, + topOverride, + animatedLeft, + animatedTop, + ]); + + const prevFlipRef = useRef(shouldFlipX); + const [flipKey, setFlipKey] = useState(0); + + useEffect(() => { + if (prevFlipRef.current !== shouldFlipX) { + setFlipKey((k) => k + 1); + prevFlipRef.current = shouldFlipX; + } + }, [shouldFlipX]); + + const finalLeft = leftOverride ?? animatedLeft; + const finalTop = topOverride ?? animatedTop; + const isFlipped = flippedOverride ?? shouldFlipX; + const transformOrigin = isFlipped ? "right top" : "left top"; + + return createPortal( + + + {children} + + , + container, + ); +} + +TooltipBox.displayName = "TooltipBox"; + +export default TooltipBox; diff --git a/ui/components/charts/tooltip/tooltip-content.tsx b/ui/components/charts/tooltip/tooltip-content.tsx new file mode 100644 index 00000000..0641343f --- /dev/null +++ b/ui/components/charts/tooltip/tooltip-content.tsx @@ -0,0 +1,63 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { intFmt } from "../chart-formatters"; + +export interface TooltipRow { + color: string; + label: string; + value: string | number; +} + +export interface TooltipContentProps { + title?: string; + rows: TooltipRow[]; + /** Optional additional content (e.g., markers) */ + children?: ReactNode; +} + +export function TooltipContent({ title, rows, children }: TooltipContentProps) { + return ( +
+
+ {title && ( +
+ {title} +
+ )} +
+ {rows.map((row) => ( +
+
+ + + {row.label} + +
+ + {typeof row.value === "number" ? intFmt(row.value) : row.value} + +
+ ))} +
+ + {children && ( +
+ {children} +
+ )} +
+
+ ); +} + +TooltipContent.displayName = "TooltipContent"; + +export default TooltipContent; diff --git a/ui/components/charts/tooltip/tooltip-dot.tsx b/ui/components/charts/tooltip/tooltip-dot.tsx new file mode 100644 index 00000000..879a84f5 --- /dev/null +++ b/ui/components/charts/tooltip/tooltip-dot.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; + +import { type SpringConfig, useChartConfig } from "../chart-config-context"; +import { chartCssVars } from "../chart-context"; + +export interface TooltipDotProps { + x: number; + y: number; + visible: boolean; + color: string; + size?: number; + strokeColor?: string; + strokeWidth?: number; + /** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */ + springConfig?: SpringConfig; +} + +export function TooltipDot({ + x, + y, + visible, + color, + size = 5, + strokeColor = chartCssVars.background, + strokeWidth = 2, + springConfig, +}: TooltipDotProps) { + const { tooltipSpring } = useChartConfig(); + const effectiveSpring = springConfig ?? tooltipSpring; + const animatedX = useSpring(x, effectiveSpring); + const animatedY = useSpring(y, effectiveSpring); + + animatedX.set(x); + animatedY.set(y); + + if (!visible) { + return null; + } + + return ( + + ); +} + +TooltipDot.displayName = "TooltipDot"; + +export default TooltipDot; diff --git a/ui/components/charts/tooltip/tooltip-indicator.tsx b/ui/components/charts/tooltip/tooltip-indicator.tsx new file mode 100644 index 00000000..3031210b --- /dev/null +++ b/ui/components/charts/tooltip/tooltip-indicator.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { motion, useSpring } from "motion/react"; +import { useEffect } from "react"; + +import { type SpringConfig, useChartConfig } from "../chart-config-context"; +import { chartCssVars } from "../chart-context"; + +export type IndicatorWidth = + | number // Pixel width + | "line" // 1px line (default) + | "thin" // 2px + | "medium" // 4px + | "thick"; // 8px + +export interface TooltipIndicatorProps { + /** X position in pixels (center of the indicator) */ + x: number; + /** Height of the indicator */ + height: number; + /** Whether the indicator is visible */ + visible: boolean; + /** + * Width of the indicator - number (pixels) or preset. + * Ignored if `span` is provided. + */ + width?: IndicatorWidth; + /** + * Number of columns/days to span, with current point centered. + * Requires `columnWidth` to be set. + */ + span?: number; + /** Width of a single column/day in pixels. Required when using `span`. */ + columnWidth?: number; + /** Primary color at edges (10% and 90%) */ + colorEdge?: string; + /** Secondary color at center (50%) */ + colorMid?: string; + /** Whether to fade to transparent at 0% and 100% */ + fadeEdges?: boolean; + /** Animate position with a spring. Default: true */ + animate?: boolean; + /** Unique ID for the gradient */ + gradientId?: string; + /** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */ + springConfig?: SpringConfig; +} + +function resolveWidth(width: IndicatorWidth): number { + if (typeof width === "number") { + return width; + } + switch (width) { + case "line": + return 1; + case "thin": + return 2; + case "medium": + return 4; + case "thick": + return 8; + default: + return 1; + } +} + +// Inner-only-on-visible so `useSpring` initializes at the real cursor x +// instead of 0 on first hover. +export function TooltipIndicator(props: TooltipIndicatorProps) { + if (!props.visible) { + return null; + } + return ; +} + +function TooltipIndicatorInner({ + x, + visible, + height, + width = "line", + span, + columnWidth, + colorEdge = chartCssVars.crosshair, + colorMid = chartCssVars.crosshair, + fadeEdges = true, + animate = true, + gradientId = "tooltip-indicator-gradient", + springConfig, +}: TooltipIndicatorProps) { + const { tooltipSpring } = useChartConfig(); + const effectiveSpring = springConfig ?? tooltipSpring; + + const pixelWidth = + span !== undefined && columnWidth !== undefined + ? span * columnWidth + : resolveWidth(width); + + const rectX = x - pixelWidth / 2; + const animatedX = useSpring(rectX, effectiveSpring); + + if (animate) { + animatedX.set(rectX); + } + + // biome-ignore lint/correctness/useExhaustiveDependencies: we need to jump the animatedX when the visible prop changes + useEffect(() => { + animatedX.set(rectX); + }, [animatedX, rectX, visible]); + + const edgeOpacity = fadeEdges ? 0 : 1; + + return ( + + + + + + + + + + + {animate ? ( + + ) : ( + + )} + + ); +} + +TooltipIndicator.displayName = "TooltipIndicator"; + +export default TooltipIndicator; diff --git a/ui/components/charts/use-chart-interaction.ts b/ui/components/charts/use-chart-interaction.ts new file mode 100644 index 00000000..cd153493 --- /dev/null +++ b/ui/components/charts/use-chart-interaction.ts @@ -0,0 +1,331 @@ +"use client"; + +import { localPoint } from "@visx/event"; +import type { scaleLinear, scaleTime } from "@visx/scale"; +import { useCallback, useRef, useState } from "react"; + +import type { LineConfig, Margin, TooltipData } from "./chart-context"; +import { useScheduledTooltip } from "./use-scheduled-tooltip"; + +type ScaleTime = ReturnType>; +type ScaleLinear = ReturnType>; + +export interface ChartSelection { + startX: number; + endX: number; + startIndex: number; + endIndex: number; + active: boolean; +} + +interface UseChartInteractionParams { + xScale: ScaleTime; + yScale: ScaleLinear; + data: Record[]; + lines: LineConfig[]; + margin: Margin; + xAccessor: (d: Record) => Date; + bisectDate: ( + data: Record[], + date: Date, + lo: number, + ) => number; + canInteract: boolean; +} + +interface ChartInteractionResult { + tooltipData: TooltipData | null; + setTooltipData: React.Dispatch>; + selection: ChartSelection | null; + clearSelection: () => void; + interactionHandlers: { + onMouseMove?: (event: React.MouseEvent) => void; + onMouseLeave?: () => void; + onMouseDown?: (event: React.MouseEvent) => void; + onMouseUp?: () => void; + onTouchStart?: (event: React.TouchEvent) => void; + onTouchMove?: (event: React.TouchEvent) => void; + onTouchEnd?: () => void; + }; + interactionStyle: React.CSSProperties; +} + +export function useChartInteraction({ + xScale, + yScale, + data, + lines, + margin, + xAccessor, + bisectDate, + canInteract, +}: UseChartInteractionParams): ChartInteractionResult { + const [selection, setSelection] = useState(null); + const { + tooltipData, + setTooltipData, + scheduleTooltip, + clearTooltip, + resetTooltipDedupe, + } = useScheduledTooltip(); + + const isDraggingRef = useRef(false); + const dragStartXRef = useRef(0); + + const resolveTooltipFromX = useCallback( + (pixelX: number): TooltipData | null => { + const x0 = xScale.invert(pixelX); + const index = bisectDate(data, x0, 1); + const d0 = data[index - 1]; + const d1 = data[index]; + + if (!d0) { + return null; + } + + let d = d0; + let finalIndex = index - 1; + if (d1) { + const d0Time = xAccessor(d0).getTime(); + const d1Time = xAccessor(d1).getTime(); + if (x0.getTime() - d0Time > d1Time - x0.getTime()) { + d = d1; + finalIndex = index; + } + } + + const yPositions: Record = {}; + for (const line of lines) { + const value = d[line.dataKey]; + if (typeof value === "number") { + yPositions[line.dataKey] = yScale(value) ?? 0; + } + } + + return { + point: d, + index: finalIndex, + x: xScale(xAccessor(d)) ?? 0, + yPositions, + }; + }, + [xScale, yScale, data, lines, xAccessor, bisectDate], + ); + + const resolveIndexFromX = 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], + ); + + const getChartX = useCallback( + ( + event: React.MouseEvent | React.TouchEvent, + touchIndex = 0, + ): number | null => { + let point: { x: number; y: number } | null = null; + + if ("touches" in event) { + const touch = event.touches[touchIndex]; + if (!touch) { + return null; + } + const svg = event.currentTarget.ownerSVGElement; + if (!svg) { + return null; + } + point = localPoint(svg, touch as unknown as MouseEvent); + } else { + point = localPoint(event); + } + + if (!point) { + return null; + } + return point.x - margin.left; + }, + [margin.left], + ); + + const handleMouseMove = useCallback( + (event: React.MouseEvent) => { + const chartX = getChartX(event); + if (chartX === null) { + return; + } + + if (isDraggingRef.current) { + const startX = Math.min(dragStartXRef.current, chartX); + const endX = Math.max(dragStartXRef.current, chartX); + setSelection({ + startX, + endX, + startIndex: resolveIndexFromX(startX), + endIndex: resolveIndexFromX(endX), + active: true, + }); + return; + } + + const tooltip = resolveTooltipFromX(chartX); + if (tooltip) { + scheduleTooltip(tooltip); + } + }, + [getChartX, resolveTooltipFromX, resolveIndexFromX, scheduleTooltip], + ); + + const handleMouseLeave = useCallback(() => { + clearTooltip(); + if (isDraggingRef.current) { + isDraggingRef.current = false; + } + setSelection(null); + }, [clearTooltip]); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + const chartX = getChartX(event); + if (chartX === null) { + return; + } + isDraggingRef.current = true; + dragStartXRef.current = chartX; + clearTooltip(); + setSelection(null); + }, + [getChartX, clearTooltip], + ); + + const handleMouseUp = useCallback(() => { + if (isDraggingRef.current) { + isDraggingRef.current = false; + } + setSelection(null); + }, []); + + const handleTouchStart = useCallback( + (event: React.TouchEvent) => { + if (event.touches.length === 1) { + event.preventDefault(); + const chartX = getChartX(event, 0); + if (chartX === null) { + return; + } + const tooltip = resolveTooltipFromX(chartX); + if (tooltip) { + scheduleTooltip(tooltip); + } + } else if (event.touches.length === 2) { + event.preventDefault(); + resetTooltipDedupe(); + clearTooltip(); + const x0 = getChartX(event, 0); + const x1 = getChartX(event, 1); + if (x0 === null || x1 === null) { + return; + } + const startX = Math.min(x0, x1); + const endX = Math.max(x0, x1); + setSelection({ + startX, + endX, + startIndex: resolveIndexFromX(startX), + endIndex: resolveIndexFromX(endX), + active: true, + }); + } + }, + [ + getChartX, + resolveTooltipFromX, + resolveIndexFromX, + scheduleTooltip, + resetTooltipDedupe, + clearTooltip, + ], + ); + + const handleTouchMove = useCallback( + (event: React.TouchEvent) => { + if (event.touches.length === 1) { + event.preventDefault(); + const chartX = getChartX(event, 0); + if (chartX === null) { + return; + } + const tooltip = resolveTooltipFromX(chartX); + if (tooltip) { + scheduleTooltip(tooltip); + } + } else if (event.touches.length === 2) { + event.preventDefault(); + const x0 = getChartX(event, 0); + const x1 = getChartX(event, 1); + if (x0 === null || x1 === null) { + return; + } + const startX = Math.min(x0, x1); + const endX = Math.max(x0, x1); + setSelection({ + startX, + endX, + startIndex: resolveIndexFromX(startX), + endIndex: resolveIndexFromX(endX), + active: true, + }); + } + }, + [getChartX, resolveTooltipFromX, resolveIndexFromX, scheduleTooltip], + ); + + const handleTouchEnd = useCallback(() => { + clearTooltip(); + setSelection(null); + }, [clearTooltip]); + + const clearSelection = useCallback(() => { + setSelection(null); + }, []); + + const interactionHandlers = canInteract + ? { + onMouseMove: handleMouseMove, + onMouseLeave: handleMouseLeave, + onMouseDown: handleMouseDown, + onMouseUp: handleMouseUp, + onTouchStart: handleTouchStart, + onTouchMove: handleTouchMove, + onTouchEnd: handleTouchEnd, + } + : {}; + + const interactionStyle: React.CSSProperties = { + cursor: canInteract ? "crosshair" : "default", + touchAction: "none", + }; + + return { + tooltipData, + setTooltipData, + selection, + clearSelection, + interactionHandlers, + interactionStyle, + }; +} diff --git a/ui/components/charts/use-highlight-segment.ts b/ui/components/charts/use-highlight-segment.ts new file mode 100644 index 00000000..ba625694 --- /dev/null +++ b/ui/components/charts/use-highlight-segment.ts @@ -0,0 +1,62 @@ +"use client"; + +import { useSpring } from "motion/react"; +import { useMemo, useRef } from "react"; + +import { useChartConfig } from "./chart-config-context"; +import { useChartHover, useChartStable } from "./chart-context"; +import { + computeSegmentBounds, + INACTIVE_SEGMENT, +} from "./highlight-segment-bounds"; + +// Hover-highlight band for `line.tsx` and `area.tsx`. Computes the segment +// bounds and springs its x/width; `` renders the clipped +// re-stroke. Spring tuning comes from `ChartConfigProvider.highlightSpring`. +// Stable + hover slices are read separately so callers can see the exact +// subscription surface (anything calling this hook will re-render on hover). + +export interface HighlightSegmentResult { + xSpring: ReturnType; + widthSpring: ReturnType; + isActive: boolean; +} + +/** + * @param enabled set false when there is no stroke to highlight (e.g. an area + * with `showLine={false}`); defaults true. + */ +export function useHighlightSegment({ + enabled = true, +}: { + enabled?: boolean; +} = {}): HighlightSegmentResult { + const { data, xScale, xAccessor } = useChartStable(); + const { tooltipData, selection } = useChartHover(); + const { highlightSpring } = useChartConfig(); + + const bounds = useMemo( + () => + enabled + ? computeSegmentBounds(data, xScale, xAccessor, tooltipData, selection) + : INACTIVE_SEGMENT, + [enabled, data, xScale, xAccessor, tooltipData, selection], + ); + + const xSpring = useSpring(0, highlightSpring); + const widthSpring = useSpring(0, highlightSpring); + + // Jump on inactive→active so the band appears at the hovered point instead + // of sliding in from x=0; ease on subsequent moves. + 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; + + return { xSpring, widthSpring, isActive: bounds.isActive }; +} diff --git a/ui/components/charts/use-mount-progress.ts b/ui/components/charts/use-mount-progress.ts new file mode 100644 index 00000000..8a8637ac --- /dev/null +++ b/ui/components/charts/use-mount-progress.ts @@ -0,0 +1,30 @@ +"use client"; + +import { animate, type Transition, useMotionValue } from "motion/react"; +import { useEffect, useRef } from "react"; + +import { DEFAULT_CHART_ENTER_TRANSITION } from "./animation"; + +/** Drives 0→1 enter progress using the studio motion transition (spring or tween). */ +export function useMountProgress( + enterTransition: Transition | undefined, + delaySeconds: number, + replayKey: number | string, +) { + const progress = useMotionValue(0); + const transitionRef = useRef(enterTransition); + transitionRef.current = enterTransition; + + // replayKey intentionally retriggers enter when motion settings change + // biome-ignore lint/correctness/useExhaustiveDependencies: replayKey + useEffect(() => { + progress.set(0); + const controls = animate(progress, 1, { + ...(transitionRef.current ?? DEFAULT_CHART_ENTER_TRANSITION), + delay: delaySeconds, + }); + return () => controls.stop(); + }, [delaySeconds, replayKey, progress]); + + return progress; +} diff --git a/ui/components/charts/use-scheduled-tooltip.ts b/ui/components/charts/use-scheduled-tooltip.ts new file mode 100644 index 00000000..4ada019a --- /dev/null +++ b/ui/components/charts/use-scheduled-tooltip.ts @@ -0,0 +1,93 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface ScheduledTooltipControls { + tooltipData: T | null; + setTooltipData: React.Dispatch>; + scheduleTooltip: (tooltip: T, dedupeKey?: string) => void; + clearTooltip: () => void; + resetTooltipDedupe: () => void; +} + +function defaultDedupeKey(tooltip: T): string { + if ( + typeof tooltip === "object" && + tooltip !== null && + "index" in tooltip && + typeof (tooltip as { index: unknown }).index === "number" + ) { + return String((tooltip as { index: number }).index); + } + return JSON.stringify(tooltip); +} + +export function useScheduledTooltip(): ScheduledTooltipControls { + const [tooltipData, setTooltipData] = useState(null); + const lastKeyRef = useRef(null); + const pendingRef = useRef(null); + const rafRef = useRef(null); + const pendingKeyRef = useRef(null); + + useEffect(() => { + return () => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + } + }; + }, []); + + const commitTooltip = useCallback((tooltip: T, dedupeKey: string) => { + if (dedupeKey === lastKeyRef.current) { + return; + } + lastKeyRef.current = dedupeKey; + setTooltipData(tooltip); + }, []); + + const scheduleTooltip = useCallback( + (tooltip: T, dedupeKey?: string) => { + const key = dedupeKey ?? defaultDedupeKey(tooltip); + pendingRef.current = tooltip; + pendingKeyRef.current = key; + if (key === lastKeyRef.current) { + return; + } + if (rafRef.current !== null) { + return; + } + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + const next = pendingRef.current; + const nextKey = pendingKeyRef.current; + if (next && nextKey) { + commitTooltip(next, nextKey); + } + }); + }, + [commitTooltip], + ); + + const clearTooltip = useCallback(() => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + pendingRef.current = null; + pendingKeyRef.current = null; + lastKeyRef.current = null; + setTooltipData(null); + }, []); + + const resetTooltipDedupe = useCallback(() => { + lastKeyRef.current = null; + }, []); + + return { + tooltipData, + setTooltipData, + scheduleTooltip, + clearTooltip, + resetTooltipDedupe, + }; +} diff --git a/ui/components/charts/x-axis.tsx b/ui/components/charts/x-axis.tsx new file mode 100644 index 00000000..faaddc45 --- /dev/null +++ b/ui/components/charts/x-axis.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { memo, useEffect, useMemo, useState } from "react"; +import { createPortal } from "react-dom"; + +import { cn } from "@/ui/lib/utils"; + +import { useChart, useChartStable } from "./chart-context"; +import { shortDateFmt } from "./chart-formatters"; + +export interface XAxisProps { + /** Number of ticks to show (including first and last). Default: 5. Used when `tickMode` is `"domain"`. */ + numTicks?: number; + /** Width of the date ticker box for fade calculation. Default: 50 */ + tickerHalfWidth?: number; + /** + * `"domain"` — evenly spaced ticks across the time domain (default). + * `"data"` — one label per data row at its x value (better with sparse or monthly bars). + */ + tickMode?: "domain" | "data"; +} + +interface XAxisLabelProps { + label: string; + x: number; + crosshairX: number | null; + isHovering: boolean; + tickerHalfWidth: number; +} + +function XAxisLabel({ + label, + x, + crosshairX, + isHovering, + tickerHalfWidth, +}: XAxisLabelProps) { + const fadeBuffer = 20; + const fadeRadius = tickerHalfWidth + fadeBuffer; + + let opacity = 1; + if (isHovering && crosshairX !== null) { + const distance = Math.abs(x - crosshairX); + if (distance < tickerHalfWidth) { + opacity = 0; + } else if (distance < fadeRadius) { + opacity = (distance - tickerHalfWidth) / fadeBuffer; + } + } + + // Zero-width container approach for perfect centering + // The wrapper is positioned exactly at x with width:0 + // The inner span overflows and is centered via text-align + return ( +
+ + {label} + +
+ ); +} + +export function XAxis(props: XAxisProps) { + const { containerRef } = useChartStable(); + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + const container = containerRef.current; + if (!(mounted && container)) { + return null; + } + + return ; +} + +const XAxisInner = memo(function XAxisInner({ + numTicks = 5, + tickerHalfWidth = 50, + tickMode = "domain", + container, +}: XAxisProps & { container: HTMLDivElement }) { + const { xScale, margin, tooltipData, data, xAccessor, dateLabels } = + useChart(); + + // Generate tick labels: evenly spaced along the domain, or one per data row + const labelsToShow = useMemo(() => { + if (tickMode === "data") { + return data.map((d, i) => ({ + date: xAccessor(d), + x: (xScale(xAccessor(d)) ?? 0) + margin.left, + label: dateLabels[i] ?? shortDateFmt.format(xAccessor(d)), + })); + } + + const domain = xScale.domain(); + const startDate = domain[0]; + const endDate = domain[1]; + + if (!(startDate && endDate)) { + return []; + } + + const startTime = startDate.getTime(); + const endTime = endDate.getTime(); + const timeRange = endTime - startTime; + + // Create evenly spaced dates from start to end + const tickCount = Math.max(2, numTicks); // At least first and last + const dates: Date[] = []; + + for (let i = 0; i < tickCount; i++) { + const t = i / (tickCount - 1); // 0 to 1 + const time = startTime + t * timeRange; + dates.push(new Date(time)); + } + + return dates.map((date) => ({ + date, + x: (xScale(date) ?? 0) + margin.left, + label: shortDateFmt.format(date), + })); + }, [tickMode, data, xAccessor, xScale, margin.left, dateLabels, numTicks]); + + const isHovering = tooltipData !== null; + const crosshairX = tooltipData ? tooltipData.x + margin.left : null; + + return createPortal( +
+ {labelsToShow.map((item) => ( + + ))} +
, + container, + ); +}); + +XAxis.displayName = "XAxis"; + +export default XAxis; diff --git a/ui/components/ui/card.tsx b/ui/components/ui/card.tsx index ca95c35d..b09b334d 100644 --- a/ui/components/ui/card.tsx +++ b/ui/components/ui/card.tsx @@ -9,7 +9,7 @@ const Card = React.forwardRef<
{ + document.body.innerHTML = ""; +}); + +describe("SelectContent", () => { + it("uses the Studio sans font inside the portal", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + await act(async () => { + root.render( + , + ); + + await Promise.resolve(); + }); + + expect(document.body.innerHTML).toContain("Rows returned high to low"); + expect( + document.body.querySelector('[role="listbox"]')?.className, + ).toContain("font-sans"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); +}); diff --git a/ui/components/ui/select.tsx b/ui/components/ui/select.tsx index 2407da27..85bc9a32 100644 --- a/ui/components/ui/select.tsx +++ b/ui/components/ui/select.tsx @@ -80,7 +80,7 @@ const SelectContent = React.forwardRef< , - VariantProps {} + VariantProps { + container?: HTMLElement | null; +} const SheetContent = React.forwardRef< React.ElementRef, SheetContentProps ->(({ side = "right", className, children, ...props }, ref) => ( - - - - - - Close - - {children} - +>(({ side = "right", className, children, container, ...props }, ref) => ( + +
+ + + + + Close + + {children} + +
)); SheetContent.displayName = SheetPrimitive.Content.displayName; diff --git a/ui/hooks/use-introspection.test.tsx b/ui/hooks/use-introspection.test.tsx index 4f5832df..fcff8aca 100644 --- a/ui/hooks/use-introspection.test.tsx +++ b/ui/hooks/use-introspection.test.tsx @@ -13,12 +13,15 @@ import type { import type { StudioEventBase } from "../studio/Studio"; import { useIntrospection } from "./use-introspection"; -const useStudioMock = vi.fn< - () => { - adapter: Adapter; - onEvent: (event: StudioEventBase) => void; - } ->(); +const useStudioMock = vi.hoisted(() => + vi.fn< + () => { + adapter: Adapter; + hasDatabase: boolean; + onEvent: (event: StudioEventBase) => void; + } + >(), +); vi.mock("../studio/context", () => ({ useStudio: useStudioMock, @@ -116,6 +119,7 @@ function renderHarness(args: { useStudioMock.mockReturnValue({ adapter, + hasDatabase: true, onEvent, }); diff --git a/ui/hooks/use-navigation.tsx b/ui/hooks/use-navigation.tsx index 493a2af8..977fb990 100644 --- a/ui/hooks/use-navigation.tsx +++ b/ui/hooks/use-navigation.tsx @@ -110,8 +110,13 @@ function buildNavigationTableNames(introspection: AdapterIntrospectResult) { * implement a specialized hook instead. */ function useNavigationInternal() { - const { adapter, hasDatabase, navigationTableNamesCollection, streamsUrl } = - useStudio(); + const { + adapter, + hasDatabase, + hasQueryInsights, + navigationTableNamesCollection, + streamsUrl, + } = useStudio(); const { data: introspection, isFetching } = useIntrospection(); const { schemas } = introspection; @@ -216,7 +221,11 @@ function useNavigationInternal() { ? activeTables[resolvedTableParam] : undefined; const resolvedViewParam = - !hasDatabase && typeof streamsUrl === "string" ? "stream" : viewParam; + !hasDatabase && typeof streamsUrl === "string" + ? "stream" + : viewParam === "queries" && !hasQueryInsights + ? defaults.view + : viewParam; const metadata = useMemo( () => ({ diff --git a/ui/index.css b/ui/index.css index 4c3ec99f..ef53bc8f 100644 --- a/ui/index.css +++ b/ui/index.css @@ -56,6 +56,36 @@ --animate-indeterminate: indeterminate 1.5s infinite ease-in-out; + --color-chart-label: var(--chart-label); + + --color-chart-ring-background: var(--chart-ring-background); + + --color-chart-marker-foreground: var(--chart-marker-foreground); + + --color-chart-marker-border: var(--chart-marker-border); + + --color-chart-marker-background: var(--chart-marker-background); + + --color-chart-tooltip-muted: var(--chart-tooltip-muted); + + --color-chart-tooltip-foreground: var(--chart-tooltip-foreground); + + --color-chart-tooltip-background: var(--chart-tooltip-background); + + --color-chart-grid: var(--chart-grid); + + --color-chart-crosshair: var(--chart-crosshair); + + --chart-line-secondary: var(--chart-line-secondary); + + --chart-line-primary: var(--chart-line-primary); + + --color-chart-foreground-muted: var(--chart-foreground-muted); + + --color-chart-foreground: var(--chart-foreground); + + --color-chart-background: var(--chart-background); + @keyframes indeterminate { 0% { transform: translateX(-100%); @@ -174,6 +204,21 @@ --studio-staged-cell-background: oklch(0.97 0.025 95 / 0.78); --studio-staged-cell-background-hover: oklch(0.955 0.035 95 / 0.84); --text-xs: 0.75rem; + --chart-background: oklch(1 0 0); + --chart-foreground: oklch(0.145 0.004 285); + --chart-foreground-muted: oklch(0.55 0.014 260); + --chart-line-primary: var(--chart-1); + --chart-line-secondary: var(--chart-2); + --chart-crosshair: oklch(0.4 0.1828 274.34); + --chart-grid: oklch(0.9 0 0); + --chart-tooltip-background: oklch(0.21 0.006 285 / 0.8); + --chart-tooltip-foreground: oklch(0.985 0 0); + --chart-tooltip-muted: oklch(0.65 0.01 260); + --chart-marker-background: oklch(0.97 0.005 260); + --chart-marker-border: oklch(0.85 0.01 260); + --chart-marker-foreground: oklch(0.3 0.01 260); + --chart-ring-background: oklch(0.9 0.005 260 / 0.25); + --chart-label: oklch(0.45 0.01 260); } .dark { @@ -203,6 +248,16 @@ --chart-5: oklch(0.645 0.246 16.439); --studio-staged-cell-background: oklch(0.58 0.042 92 / 0.18); --studio-staged-cell-background-hover: oklch(0.63 0.05 92 / 0.24); + --chart-background: oklch(0.145 0 0); + --chart-foreground: oklch(0.45 0 0); + --chart-foreground-muted: oklch(0.65 0.01 260); + --chart-crosshair: oklch(0.45 0 0); + --chart-grid: oklch(0.25 0 0); + --chart-marker-background: oklch(0.25 0.01 260); + --chart-marker-border: oklch(0.4 0.01 260); + --chart-marker-foreground: oklch(0.9 0 0); + --chart-ring-background: oklch(0.35 0.01 260 / 0.25); + --chart-label: oklch(0.75 0.01 260); } @layer base { diff --git a/ui/studio/CommandPalette.test.tsx b/ui/studio/CommandPalette.test.tsx index 21cb7b6c..f3729e2d 100644 --- a/ui/studio/CommandPalette.test.tsx +++ b/ui/studio/CommandPalette.test.tsx @@ -150,6 +150,8 @@ vi.mock("../hooks/use-ui-state", async () => { vi.mock("./context", () => ({ useStudio: () => ({ + hasDatabase: true, + hasQueryInsights: false, isDarkMode, isNavigationOpen, setThemeMode: setThemeModeMock, diff --git a/ui/studio/Navigation.test.tsx b/ui/studio/Navigation.test.tsx index ef86d614..7555b414 100644 --- a/ui/studio/Navigation.test.tsx +++ b/ui/studio/Navigation.test.tsx @@ -17,7 +17,7 @@ interface NavigationMockValue { setSchemaParam: () => Promise; setTableParam: () => Promise; streamParam: string | null; - viewParam: "table" | "schema" | "console" | "sql" | "stream"; + viewParam: "table" | "schema" | "queries" | "console" | "sql" | "stream"; } interface IntrospectionMockValue { @@ -56,6 +56,7 @@ interface StreamsMockValue { interface StudioMockValue { hasDatabase: boolean; isDarkMode: boolean; + hasQueryInsights: boolean; navigationWidth: number; setNavigationWidth: (width: number) => void; } @@ -266,6 +267,7 @@ describe("Navigation", () => { refetchStreamsMock.mockResolvedValue(undefined); useStudioMock.mockImplementation(() => ({ hasDatabase: true, + hasQueryInsights: false, isDarkMode, navigationWidth: 192, setNavigationWidth: setNavigationWidthMock, @@ -430,6 +432,7 @@ describe("Navigation", () => { it("hides schema and table navigation when the session has no database", () => { useStudioMock.mockImplementation(() => ({ hasDatabase: false, + hasQueryInsights: false, isDarkMode, navigationWidth: 192, setNavigationWidth: setNavigationWidthMock, @@ -461,6 +464,7 @@ describe("Navigation", () => { expect(container.textContent).not.toContain("Tables"); expect(container.textContent).not.toContain("Visualizer"); + expect(container.textContent).not.toContain("Queries"); expect(container.textContent).not.toContain("Console"); expect(container.textContent).not.toContain("SQL"); expect(container.querySelector('button[aria-label="Schema"]')).toBeNull(); @@ -476,6 +480,76 @@ describe("Navigation", () => { container.remove(); }); + it("shows Queries directly under Visualizer only when query insights are configured", () => { + const withoutQueriesContainer = document.createElement("div"); + document.body.appendChild(withoutQueriesContainer); + const withoutQueriesRoot = createRoot(withoutQueriesContainer); + + act(() => { + withoutQueriesRoot.render(); + }); + + expect(withoutQueriesContainer.textContent).toContain("Visualizer"); + expect(withoutQueriesContainer.textContent).not.toContain("Queries"); + + act(() => { + withoutQueriesRoot.unmount(); + }); + withoutQueriesContainer.remove(); + + useStudioMock.mockImplementation(() => ({ + hasDatabase: true, + hasQueryInsights: true, + isDarkMode, + navigationWidth: 192, + setNavigationWidth: setNavigationWidthMock, + })); + useNavigationMock.mockReturnValue({ + createUrl(values: Record) { + return `#${Object.entries(values) + .map(([key, value]) => `${key}=${value}`) + .join("&")}`; + }, + metadata: { + activeTable: { name: "organizations", schema: "public" }, + isFetching: false, + }, + schemaParam: "public", + setSchemaParam: vi.fn(() => Promise.resolve(new URLSearchParams())), + setTableParam: vi.fn(() => Promise.resolve(new URLSearchParams())), + streamParam: null, + viewParam: "queries", + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const studioLinks = Array.from( + container.querySelectorAll( + 'nav[aria-label="Studio"] a', + ), + ).map((link) => link.textContent?.trim()); + + expect(studioLinks.slice(0, 2)).toEqual(["Visualizer", "Queries"]); + + const queriesLink = Array.from(container.querySelectorAll("a")).find( + (link) => link.textContent?.trim() === "Queries", + ); + + expect(queriesLink?.getAttribute("href")).toBe("#viewParam=queries"); + expect(queriesLink?.getAttribute("data-active")).toBe("true"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + it("closes table search on blur when the search input is empty", () => { const container = document.createElement("div"); document.body.appendChild(container); diff --git a/ui/studio/Navigation.tsx b/ui/studio/Navigation.tsx index 8c048906..a107cb8a 100644 --- a/ui/studio/Navigation.tsx +++ b/ui/studio/Navigation.tsx @@ -41,8 +41,13 @@ type NavigationProps = { export function Navigation({ className }: NavigationProps) { const { metadata, createUrl, streamParam, viewParam, schemaParam } = useNavigation(); - const { hasDatabase, isDarkMode, navigationWidth, setNavigationWidth } = - useStudio(); + const { + hasDatabase, + hasQueryInsights, + isDarkMode, + navigationWidth, + setNavigationWidth, + } = useStudio(); const { isFetching, activeTable } = metadata; const { errorState, hasResolvedIntrospection, isRefetching, refetch } = useIntrospection(); @@ -517,6 +522,20 @@ export function Navigation({ className }: NavigationProps) { Visualizer + {hasQueryInsights && ( + + + Queries + + + )} ({ ConsoleView: () =>
Console view
, })); +vi.mock("./views/queries/QueriesView", () => ({ + QueriesView: () =>
Queries view
, +})); + vi.mock("./views/schema/SchemaView", () => ({ SchemaView: () =>
Schema view
, })); @@ -112,6 +117,7 @@ describe("Studio", () => { refetchMock.mockClear(); useStudioMock.mockReturnValue({ hasDatabase: true, + hasQueryInsights: false, isNavigationOpen: true, streamsUrl: "/api/streams", }); @@ -255,6 +261,7 @@ describe("Studio", () => { it("renders a database-unavailable placeholder for database views when the session has no database", () => { useStudioMock.mockReturnValue({ hasDatabase: false, + hasQueryInsights: false, isNavigationOpen: true, streamsUrl: "/api/streams", }); @@ -300,4 +307,48 @@ describe("Studio", () => { }); container.remove(); }); + + it("renders the Queries view when query insights are configured and selected", () => { + useStudioMock.mockReturnValue({ + hasDatabase: true, + hasQueryInsights: true, + isNavigationOpen: true, + streamsUrl: "/api/streams", + }); + useNavigationMock.mockReturnValue({ + metadata: { + activeTable: undefined, + }, + viewParam: "queries", + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + expect(container.textContent).toContain("Queries view"); + expect(container.textContent).not.toContain("Basic view"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); }); diff --git a/ui/studio/Studio.tsx b/ui/studio/Studio.tsx index 6f7e2fe3..615afc55 100644 --- a/ui/studio/Studio.tsx +++ b/ui/studio/Studio.tsx @@ -14,6 +14,7 @@ import { IntrospectionStatusNotice } from "./IntrospectionStatusNotice"; import { Navigation } from "./Navigation"; import { StudioHeader } from "./StudioHeader"; import { ConsoleView } from "./views/console/ConsoleView"; +import { QueriesView } from "./views/queries/QueriesView"; import { SchemaView } from "./views/schema/SchemaView"; import { SqlView } from "./views/sql/SqlView"; import { StreamView } from "./views/stream/StreamView"; @@ -111,6 +112,7 @@ export function Studio(props: StudioProps) { const views: Record JSX.Element | null> = { schema: SchemaView, table: ActiveTableView, + queries: QueriesView, stream: StreamView, console: ConsoleView, sql: SqlView, diff --git a/ui/studio/context.tsx b/ui/studio/context.tsx index bc43bec9..a688e24d 100644 --- a/ui/studio/context.tsx +++ b/ui/studio/context.tsx @@ -255,9 +255,12 @@ interface StudioContextValue { adapter: Adapter; hasDatabase: boolean; llm?: StudioLlm; + queryInsights?: Adapter["queryInsights"]; streamsUrl?: string; hasAiFilter: boolean; + hasAiQueryRecommendations: boolean; hasAiSql: boolean; + hasQueryInsights: boolean; requestLlm: (request: StudioLlmRequest) => Promise; onEvent: (event: StudioEventBase) => void; operationEvents: StudioOperationEvent[]; @@ -813,7 +816,10 @@ export function StudioContextProvider(props: StudioContextProviderProps) { ); const hasAiFilter = typeof llm === "function"; + const hasAiQueryRecommendations = typeof llm === "function"; const hasAiSql = typeof llm === "function"; + const queryInsights = adapter.queryInsights; + const hasQueryInsights = typeof queryInsights?.getSnapshot === "function"; return ( @@ -823,9 +829,12 @@ export function StudioContextProvider(props: StudioContextProviderProps) { adapter, hasDatabase, llm, + queryInsights, streamsUrl, hasAiFilter, + hasAiQueryRecommendations, hasAiSql, + hasQueryInsights, requestLlm, onEvent, operationEvents, diff --git a/ui/studio/tanstack-db-performance-architecture.test.ts b/ui/studio/tanstack-db-performance-architecture.test.ts index 9fa828df..5280a2f9 100644 --- a/ui/studio/tanstack-db-performance-architecture.test.ts +++ b/ui/studio/tanstack-db-performance-architecture.test.ts @@ -54,6 +54,7 @@ describe("TanStack DB architecture compliance", () => { expect(filesWithCreateCollection).toEqual( [ "ui/hooks/use-active-table-rows-collection.ts", + "ui/hooks/use-stream-events.ts", "ui/hooks/use-ui-state.ts", "ui/studio/context.tsx", ].sort(), diff --git a/ui/studio/views/queries/QueriesView.test.tsx b/ui/studio/views/queries/QueriesView.test.tsx new file mode 100644 index 00000000..d8229410 --- /dev/null +++ b/ui/studio/views/queries/QueriesView.test.tsx @@ -0,0 +1,1772 @@ +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { StudioQueryInsights } from "../../../../data/query-insights"; +import { QueriesView } from "./QueriesView"; + +type StudioMockValue = { + hasAiQueryRecommendations: boolean; + queryInsights?: StudioQueryInsights; + requestLlm: (request: { prompt: string; task: string }) => Promise; +}; + +const useStudioMock = vi.fn<() => StudioMockValue>(); + +vi.mock("../../context", () => ({ + useStudio: () => useStudioMock(), +})); + +vi.mock("../../StudioHeader", () => ({ + StudioHeader: () =>
Studio Header
, +})); + +( + globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } +).IS_REACT_ACT_ENVIRONMENT = true; + +function click(element: Element) { + element.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + }), + ); +} + +async function flushMicrotasks() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function createAnalysisResponse(level = "info") { + return JSON.stringify({ + level, + ...(level === "all-good" + ? { + recommendations: [], + summary: "The query looks healthy.", + } + : { + improvedSql: "select id from users", + recommendations: ["Project only the columns the UI needs."], + summary: "The query over-fetches columns.", + }), + }); +} + +function createQuery(id: string, index: number) { + return { + count: 1, + duration: 10 + index, + id, + lastSeen: 1_779_963_199_000 + index, + query: `select * from query_${index}`, + reads: index, + rowsReturned: index, + tables: [`query_${index}`], + }; +} + +function getFirstPathPoint(path: string): { x: number; y: number } | null { + const match = path.match(/[ML](-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)/); + + if (!match?.[1] || !match[2]) { + return null; + } + + return { + x: Number(match[1]), + y: Number(match[2]), + }; +} + +describe("QueriesView", () => { + const getSnapshot = vi.fn(); + const requestLlm = vi.fn(); + + beforeEach(() => { + getSnapshot.mockReset(); + requestLlm.mockReset(); + getSnapshot.mockResolvedValue([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 0, + queries: [ + { + count: 3, + duration: 18, + id: "query-1", + lastSeen: 1_779_963_199_000, + query: "select * from users", + reads: 9, + rowsReturned: 3, + tables: ["users"], + }, + ], + }, + ]); + requestLlm.mockResolvedValue(createAnalysisResponse()); + useStudioMock.mockReturnValue({ + hasAiQueryRecommendations: false, + queryInsights: { + getSnapshot, + }, + requestLlm, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + document.body.innerHTML = ""; + }); + + it("renders query rows from the injected query-insights provider", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + expect(getSnapshot.mock.calls[0]?.[0]).toEqual({ limit: 500 }); + expect(getSnapshot.mock.calls[0]?.[1]?.abortSignal).toBeInstanceOf( + AbortSignal, + ); + expect(container.textContent).toContain( + "Monitor database activity and identify and fix poorly-performing queries in your application.", + ); + expect( + container.querySelector( + 'a[href="https://www.prisma.io/docs/orm/prisma-client/queries/advanced/query-optimization-performance#enabling-prisma-orm-attribution"]', + )?.textContent, + ).toBe("Find out how to see your Prisma ORM calls."); + expect( + container + .querySelector( + 'a[href="https://www.prisma.io/docs/orm/prisma-client/queries/advanced/query-optimization-performance#enabling-prisma-orm-attribution"]', + ) + ?.closest("p")?.className, + ).toContain("max-w-5xl"); + expect(container.textContent).toContain("select * from users"); + expect(container.textContent).toContain("3"); + expect(container.textContent).toContain("18ms"); + expect(container.textContent).not.toContain("Recommendations"); + expect(container.textContent).not.toContain("Analysis"); + expect(container.textContent).not.toContain("Analyze"); + expect( + container.querySelector('[aria-label="Filter queries by table"]') + ?.textContent, + ).toBe("All"); + expect( + container.querySelector('[aria-label="Sort queries"]')?.textContent, + ).toBe("Rows returned high to low"); + expect(container.textContent).toContain("Rows Returned"); + expect(container.textContent).not.toContain("Reads high to low"); + expect(container.textContent).not.toContain("Table:"); + expect(container.textContent).not.toContain("Sort:"); + expect(container.textContent).not.toContain("Live updates active"); + expect(container.textContent).not.toContain("Live updates paused"); + expect(container.textContent).not.toContain("Refresh"); + const activityChart = container.querySelector( + '[data-testid="queries-activity-chart"]', + ); + + expect(activityChart?.textContent).not.toContain("Activity"); + expect(activityChart?.textContent).toContain("Queries/s"); + expect(activityChart?.textContent).toContain("n/a"); + expect(activityChart?.textContent).toContain("Avg latency"); + expect(activityChart?.textContent).toContain("18ms"); + expect(container.textContent).not.toContain("Waiting for query activity"); + expect( + container.querySelector('[aria-label="Show the last 5m"]'), + ).not.toBeNull(); + expect( + container.querySelector('[aria-label="Show the last 1m"]'), + ).not.toBeNull(); + expect( + container.querySelector('[aria-label="Show the last 15m"]'), + ).not.toBeNull(); + expect( + container.querySelector('[aria-label="Show the last 1h"]'), + ).not.toBeNull(); + expect(container.textContent).not.toContain("Unique Queries"); + expect(container.textContent).not.toContain("Average Latency"); + expect( + container.querySelector('[data-testid="queries-table-shell"]')?.className, + ).toContain("border-border/70"); + expect( + container.querySelector('[data-testid="queries-metric-card"]'), + ).toBeNull(); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("renders a quiet activity chart empty state", async () => { + getSnapshot.mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 0, + queries: [], + }, + ]); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + const activityChart = container.querySelector( + '[data-testid="queries-activity-chart"]', + ); + const activitySvg = container.querySelector( + '[data-testid="queries-activity-svg"]', + ); + + expect(activityChart?.textContent).toContain("Waiting for query activity"); + expect(activityChart?.textContent).not.toContain("-5m"); + expect(activityChart?.textContent).not.toContain("now"); + expect(activitySvg?.querySelectorAll("line")).toHaveLength(0); + expect( + container.querySelector('[data-testid="queries-activity-queries-path"]'), + ).toBeNull(); + expect( + container.querySelector('[data-testid="queries-activity-latency-path"]'), + ).toBeNull(); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("derives live activity chart samples from query snapshot deltas", async () => { + vi.useFakeTimers(); + getSnapshot + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 3, + duration: 18, + id: "query-1", + lastSeen: 1_779_963_199_000, + query: "select * from users", + reads: 9, + rowsReturned: 3, + tables: ["users"], + }, + ], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_201_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 5, + duration: 18.8, + id: "query-1", + lastSeen: 1_779_963_201_000, + query: "select * from users", + reads: 12, + rowsReturned: 5, + tables: ["users"], + }, + ], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_202_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 5, + duration: 18.8, + id: "query-1", + lastSeen: 1_779_963_201_000, + query: "select * from users", + reads: 12, + rowsReturned: 5, + tables: ["users"], + }, + ], + }, + ]); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + + const chart = container.querySelector( + '[data-testid="queries-activity-chart"]', + ); + + expect(chart?.textContent).toContain("2.0/s"); + expect(chart?.textContent).toContain("20ms"); + expect( + container.querySelector('[data-testid="queries-activity-svg"]') + ?.className, + ).toContain("w-full"); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + + expect(chart?.textContent).toContain("1.0/s"); + expect(chart?.textContent).toContain("20ms"); + const queriesPath = + container + .querySelector('[data-testid="queries-activity-queries-path"]') + ?.getAttribute("d") ?? ""; + const latencyPath = + container + .querySelector('[data-testid="queries-activity-latency-path"]') + ?.getAttribute("d") ?? ""; + + expect(queriesPath.match(/L/g)).toHaveLength(1); + expect(latencyPath.match(/L/g)).toHaveLength(1); + + const plot = container.querySelector( + '[data-testid="queries-activity-plot"]', + ); + + expect(plot).not.toBeNull(); + + act(() => { + plot!.dispatchEvent( + new MouseEvent("pointermove", { + bubbles: true, + clientX: 620, + clientY: 40, + }), + ); + }); + + const tooltip = container.querySelector( + '[data-testid="queries-activity-tooltip"]', + ); + + expect(tooltip?.textContent).toContain("Queries/s"); + expect(tooltip?.textContent).toContain("Avg latency"); + expect( + tooltip + ?.querySelector('[data-testid="queries-activity-tooltip-queries"]') + ?.className.includes("gap-4"), + ).toBe(true); + expect( + tooltip + ?.querySelector('[data-testid="queries-activity-tooltip-queries"]') + ?.querySelector(".bg-sky-500"), + ).not.toBeNull(); + expect( + tooltip + ?.querySelector('[data-testid="queries-activity-tooltip-queries"]') + ?.querySelector(".text-sky-600"), + ).not.toBeNull(); + expect( + tooltip + ?.querySelector('[data-testid="queries-activity-tooltip-latency"]') + ?.querySelector(".bg-emerald-500"), + ).not.toBeNull(); + expect( + tooltip + ?.querySelector('[data-testid="queries-activity-tooltip-latency"]') + ?.querySelector(".text-emerald-600"), + ).not.toBeNull(); + + const oneHourButton = container.querySelector( + '[aria-label="Show the last 1h"]', + ); + + expect(oneHourButton).not.toBeNull(); + + act(() => { + click(oneHourButton!); + }); + + expect(chart?.textContent).toContain("-1h"); + + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + it("keeps cumulative first-snapshot counts out of live chart summaries after idle polling", async () => { + vi.useFakeTimers(); + getSnapshot + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 21, + duration: 5, + id: "query-1", + lastSeen: 1_779_963_185_000, + query: "select * from users", + reads: 21, + rowsReturned: 21, + tables: ["users"], + }, + ], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_201_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 21, + duration: 5, + id: "query-1", + lastSeen: 1_779_963_185_000, + query: "select * from users", + reads: 21, + rowsReturned: 21, + tables: ["users"], + }, + ], + }, + ]); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + + const chart = container.querySelector( + '[data-testid="queries-activity-chart"]', + ); + const queriesPath = + container + .querySelector('[data-testid="queries-activity-queries-path"]') + ?.getAttribute("d") ?? ""; + + expect(chart?.textContent).toContain("0/s"); + expect(chart?.textContent).toContain("5ms"); + expect(queriesPath.match(/L/g)).toBeNull(); + expect( + container.querySelectorAll( + '[data-testid="queries-activity-queries-point"]', + ), + ).toHaveLength(0); + expect( + container.querySelectorAll( + '[data-testid="queries-activity-latency-point"]', + ), + ).toHaveLength(1); + + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + it("plots newly observed executions as one-second throughput buckets", async () => { + vi.useFakeTimers(); + getSnapshot + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 1000, + queries: [], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_207_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 1, + duration: 17, + id: "query-1", + lastSeen: 1_779_963_206_000, + query: "select * from users", + reads: 1, + rowsReturned: 1, + tables: ["users"], + }, + ], + }, + ]); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + + const chart = container.querySelector( + '[data-testid="queries-activity-chart"]', + ); + const plot = container.querySelector( + '[data-testid="queries-activity-plot"]', + ); + + expect(chart?.textContent).toContain("0.14/s"); + expect(plot).not.toBeNull(); + + act(() => { + plot!.dispatchEvent( + new MouseEvent("pointermove", { + bubbles: true, + clientX: 620, + clientY: 40, + }), + ); + }); + + const tooltip = container.querySelector( + '[data-testid="queries-activity-tooltip"]', + ); + + expect(tooltip?.textContent).toContain("Queries/s"); + expect(tooltip?.textContent).toContain("1.0/s"); + expect(tooltip?.textContent).toContain("Avg latency"); + expect(tooltip?.textContent).toContain("17ms"); + + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + it("aggregates chart executions that land in the same one-second bucket", async () => { + vi.useFakeTimers(); + getSnapshot + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 1000, + queries: [], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_600, + pollingIntervalMs: 1000, + queries: [ + { + count: 1, + duration: 12, + id: "query-1", + lastSeen: 1_779_963_200_500, + query: "select * from users", + reads: 1, + rowsReturned: 1, + tables: ["users"], + }, + ], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_900, + pollingIntervalMs: 1000, + queries: [ + { + count: 2, + duration: 14, + id: "query-1", + lastSeen: 1_779_963_200_800, + query: "select * from users", + reads: 2, + rowsReturned: 2, + tables: ["users"], + }, + ], + }, + ]); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + + const chart = container.querySelector( + '[data-testid="queries-activity-chart"]', + ); + const row = container.querySelector("tbody tr"); + + expect(chart?.textContent).toContain("2.2/s"); + expect(row?.textContent).toContain("2"); + + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + it("visually separates overlapping throughput and latency peaks", async () => { + vi.useFakeTimers(); + getSnapshot + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 1000, + queries: [], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_201_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 1, + duration: 1, + id: "query-1", + lastSeen: 1_779_963_201_000, + query: "select * from users", + reads: 1, + rowsReturned: 1, + tables: ["users"], + }, + ], + }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + + const queriesPoint = getFirstPathPoint( + container + .querySelector('[data-testid="queries-activity-queries-path"]') + ?.getAttribute("d") ?? "", + ); + const latencyPoint = getFirstPathPoint( + container + .querySelector('[data-testid="queries-activity-latency-path"]') + ?.getAttribute("d") ?? "", + ); + + expect(queriesPoint).not.toBeNull(); + expect(latencyPoint).not.toBeNull(); + expect(latencyPoint!.x).toBe(queriesPoint!.x); + expect(latencyPoint!.y).toBeGreaterThan(queriesPoint!.y); + + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + it("continues chart deltas from cached totals when returning to the Queries view", async () => { + getSnapshot + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 0, + queries: [ + { + count: 10, + duration: 5, + id: "query-1", + lastSeen: 1_779_963_200_000, + query: "select * from users", + reads: 10, + rowsReturned: 10, + tables: ["users"], + }, + ], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_204_000, + pollingIntervalMs: 0, + queries: [ + { + count: 14, + duration: 5, + id: "query-1", + lastSeen: 1_779_963_204_000, + query: "select * from users", + reads: 14, + rowsReturned: 14, + tables: ["users"], + }, + ], + }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const firstRoot = createRoot(container); + + act(() => { + firstRoot.render(); + }); + await flushMicrotasks(); + + act(() => { + firstRoot.unmount(); + }); + + const secondRoot = createRoot(container); + + act(() => { + secondRoot.render(); + }); + await flushMicrotasks(); + + const chart = container.querySelector( + '[data-testid="queries-activity-chart"]', + ); + const queriesPath = + container + .querySelector('[data-testid="queries-activity-queries-path"]') + ?.getAttribute("d") ?? ""; + + expect(chart?.textContent).toContain("1.0/s"); + expect(chart?.textContent).not.toContain("14.0/s"); + expect(queriesPath.match(/L/g)).toBeNull(); + + act(() => { + secondRoot.unmount(); + }); + container.remove(); + }); + + it("breaks the live chart line across time spent away from the Queries view", async () => { + vi.useFakeTimers(); + getSnapshot + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 1000, + queries: [], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_201_000, + pollingIntervalMs: 1000, + queries: [], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_261_000, + pollingIntervalMs: 0, + queries: [ + { + count: 1, + duration: 5, + id: "query-1", + lastSeen: 1_779_963_261_000, + query: "select * from users", + reads: 1, + rowsReturned: 1, + tables: ["users"], + }, + ], + }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const firstRoot = createRoot(container); + + act(() => { + firstRoot.render(); + }); + await flushMicrotasks(); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + + act(() => { + firstRoot.unmount(); + }); + + const secondRoot = createRoot(container); + + act(() => { + secondRoot.render(); + }); + await flushMicrotasks(); + + const queriesPath = + container + .querySelector('[data-testid="queries-activity-queries-path"]') + ?.getAttribute("d") ?? ""; + + expect(queriesPath.match(/M/g)).toHaveLength(2); + expect(queriesPath.match(/L/g)).toBeNull(); + + act(() => { + secondRoot.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + it("filters query rows to the selected activity chart window", async () => { + getSnapshot.mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_600_000, + pollingIntervalMs: 0, + queries: [ + { + count: 1, + duration: 10, + id: "recent-query", + lastSeen: 1_779_963_540_000, + query: "select * from recent_events", + reads: 12, + rowsReturned: 5, + tables: ["recent_events"], + }, + { + count: 1, + duration: 20, + id: "older-query", + lastSeen: 1_779_963_000_000, + query: "select * from older_events", + reads: 24, + rowsReturned: 10, + tables: ["older_events"], + }, + ], + }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + expect(container.textContent).toContain("select * from recent_events"); + expect(container.textContent).not.toContain("select * from older_events"); + + const fifteenMinuteButton = container.querySelector( + '[aria-label="Show the last 15m"]', + ); + + expect(fifteenMinuteButton).not.toBeNull(); + + act(() => { + click(fifteenMinuteButton!); + }); + + expect(container.textContent).toContain("select * from recent_events"); + expect(container.textContent).toContain("select * from older_events"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("scopes query row counters to measured activity in the selected chart window", async () => { + vi.useFakeTimers(); + getSnapshot + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 10, + duration: 10, + id: "query-1", + lastSeen: 1_779_962_800_000, + query: "select * from users", + reads: 100, + rowsReturned: 1000, + tables: ["users"], + }, + ], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_201_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 12, + duration: 11, + id: "query-1", + lastSeen: 1_779_963_201_000, + query: "select * from users", + reads: 120, + rowsReturned: 1200, + tables: ["users"], + }, + ], + }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + + const row = [...container.querySelectorAll("tbody tr")].find((element) => + element.textContent?.includes("select * from users"), + ); + const cells = [...(row?.querySelectorAll("td") ?? [])]; + + expect(row).not.toBeUndefined(); + expect(cells[2]?.textContent).toBe("2"); + expect(cells[3]?.textContent).toBe("200"); + expect(row?.textContent).not.toContain("12"); + expect(row?.textContent).not.toContain("1.2K"); + + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + it("does not duplicate first-snapshot context rows when a stale snapshot repeats", async () => { + vi.useFakeTimers(); + getSnapshot + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 10, + duration: 10, + id: "query-1", + lastSeen: 1_779_963_199_000, + query: "select * from users", + reads: 100, + rowsReturned: 1000, + tables: ["users"], + }, + ], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 12, + duration: 12, + id: "query-1", + lastSeen: 1_779_963_200_500, + query: "select * from users", + reads: 120, + rowsReturned: 1200, + tables: ["users"], + }, + ], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_201_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 12, + duration: 12, + id: "query-1", + lastSeen: 1_779_963_200_500, + query: "select * from users", + reads: 120, + rowsReturned: 1200, + tables: ["users"], + }, + ], + }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + + const row = [...container.querySelectorAll("tbody tr")].find((element) => + element.textContent?.includes("select * from users"), + ); + const cells = [...(row?.querySelectorAll("td") ?? [])]; + + expect(getSnapshot).toHaveBeenCalledTimes(3); + expect(cells[2]?.textContent).toBe("3"); + expect(cells[3]?.textContent).toBe("300"); + + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + it("treats advanced reset counters as fresh measured activity", async () => { + vi.useFakeTimers(); + getSnapshot + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 10, + duration: 10, + id: "query-1", + lastSeen: 1_779_962_800_000, + query: "select * from users", + reads: 100, + rowsReturned: 1000, + tables: ["users"], + }, + ], + }, + ]) + .mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_201_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 2, + duration: 6, + id: "query-1", + lastSeen: 1_779_963_201_000, + query: "select * from users", + reads: 20, + rowsReturned: 200, + tables: ["users"], + }, + ], + }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + + const chart = container.querySelector( + '[data-testid="queries-activity-chart"]', + ); + const row = [...container.querySelectorAll("tbody tr")].find((element) => + element.textContent?.includes("select * from users"), + ); + const cells = [...(row?.querySelectorAll("td") ?? [])]; + + expect(chart?.textContent).toContain("2.0/s"); + expect(chart?.textContent).toContain("6ms"); + expect(cells[2]?.textContent).toBe("2"); + expect(cells[3]?.textContent).toBe("200"); + + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); + + it("renders initial snapshot history as isolated points instead of connected aggregate lines", async () => { + getSnapshot.mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_600_000, + pollingIntervalMs: 0, + queries: [ + { + count: 1, + duration: 10, + id: "query-1", + lastSeen: 1_779_960_000_000, + query: "select * from users", + reads: 12, + rowsReturned: 5, + tables: ["users"], + }, + { + count: 1, + duration: 12, + id: "query-2", + lastSeen: 1_779_960_020_000, + query: "select * from teams", + reads: 12, + rowsReturned: 5, + tables: ["teams"], + }, + { + count: 1, + duration: 30, + id: "query-3", + lastSeen: 1_779_963_540_000, + query: "select * from projects", + reads: 12, + rowsReturned: 5, + tables: ["projects"], + }, + ], + }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + const oneHourButton = container.querySelector( + '[aria-label="Show the last 1h"]', + ); + + expect(oneHourButton).not.toBeNull(); + + act(() => { + click(oneHourButton!); + }); + + const queriesPath = + container + .querySelector('[data-testid="queries-activity-queries-path"]') + ?.getAttribute("d") ?? ""; + + expect(queriesPath.match(/M/g)).toBeNull(); + expect(queriesPath.match(/L/g)).toBeNull(); + expect( + container.querySelectorAll( + '[data-testid="queries-activity-queries-point"]', + ), + ).toHaveLength(0); + expect( + container.querySelectorAll( + '[data-testid="queries-activity-latency-point"]', + ), + ).toHaveLength(3); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("uses Studio llm for recommendations only when the AI capability exists", async () => { + useStudioMock.mockReturnValue({ + hasAiQueryRecommendations: true, + queryInsights: { + getSnapshot, + }, + requestLlm, + }); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + const rowButton = [...container.querySelectorAll("button")].find((button) => + button.textContent?.includes("select * from users"), + ); + + expect(rowButton).not.toBeUndefined(); + + act(() => { + click(rowButton!); + }); + await flushMicrotasks(); + + expect(document.body.querySelector(".ps [role='dialog']")).not.toBeNull(); + expect(requestLlm).toHaveBeenCalledWith( + expect.objectContaining({ + task: "query-insights", + }), + ); + await vi.waitFor(() => { + expect(document.body.textContent).toContain( + "Project only the columns the UI needs.", + ); + }); + expect(document.body.textContent).toContain("Recommendations"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("runs automatic query analysis serially, stops after five groups, and allows manual analysis", async () => { + getSnapshot.mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 0, + queries: Array.from({ length: 6 }, (_, index) => + createQuery(`query-${index + 1}`, index + 1), + ), + }, + ]); + useStudioMock.mockReturnValue({ + hasAiQueryRecommendations: true, + queryInsights: { + getSnapshot, + }, + requestLlm, + }); + + const resolveAnalysisRequests: Array<(value: string) => void> = []; + requestLlm.mockImplementation( + () => + new Promise((resolve) => { + resolveAnalysisRequests.push(resolve); + }), + ); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + expect(container.textContent).toContain("Analysis"); + expect(requestLlm).toHaveBeenCalledTimes(1); + expect( + container.querySelectorAll('[data-testid="queries-analysis-loading"]'), + ).toHaveLength(1); + expect( + container.querySelectorAll('[data-testid="queries-analysis-queued"]'), + ).toHaveLength(4); + + for (let index = 0; index < 5; index += 1) { + await act(async () => { + resolveAnalysisRequests[index]?.(createAnalysisResponse("warning")); + await Promise.resolve(); + }); + await flushMicrotasks(); + expect(requestLlm).toHaveBeenCalledTimes(Math.min(index + 2, 5)); + } + + expect( + container.querySelectorAll( + '[aria-label="Open warning analysis for suggested fix and complete fix prompt"]', + ), + ).toHaveLength(5); + expect( + container + .querySelector( + '[aria-label="Open warning analysis for suggested fix and complete fix prompt"]', + ) + ?.getAttribute("title"), + ).toBeNull(); + expect( + container.querySelectorAll('[data-testid="queries-analysis-loading"]'), + ).toHaveLength(0); + + const manualAnalyzeButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent === "Analyze", + ); + + expect(manualAnalyzeButton).not.toBeUndefined(); + + act(() => { + click(manualAnalyzeButton!); + }); + await flushMicrotasks(); + + expect(requestLlm).toHaveBeenCalledTimes(6); + expect( + container.querySelectorAll('[data-testid="queries-analysis-loading"]'), + ).toHaveLength(1); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("lets opening a query beyond the automatic cap queue a manual analysis in the detail sheet", async () => { + getSnapshot.mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 0, + queries: Array.from({ length: 6 }, (_, index) => + createQuery(`query-${index + 1}`, index + 1), + ), + }, + ]); + useStudioMock.mockReturnValue({ + hasAiQueryRecommendations: true, + queryInsights: { + getSnapshot, + }, + requestLlm, + }); + + const resolveAnalysisRequests: Array<(value: string) => void> = []; + requestLlm.mockImplementation( + () => + new Promise((resolve) => { + resolveAnalysisRequests.push(resolve); + }), + ); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + const uncappedQueryButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("select * from query_6"), + ); + + expect(uncappedQueryButton).not.toBeUndefined(); + + act(() => { + click(uncappedQueryButton!); + }); + await flushMicrotasks(); + + expect(document.body.querySelector(".ps [role='dialog']")).not.toBeNull(); + expect(document.body.textContent).toContain( + "Waiting for the current analysis to finish.", + ); + expect(requestLlm).toHaveBeenCalledTimes(1); + + for (let index = 0; index < 5; index += 1) { + await act(async () => { + resolveAnalysisRequests[index]?.(createAnalysisResponse("info")); + await Promise.resolve(); + }); + await flushMicrotasks(); + } + + expect(requestLlm).toHaveBeenCalledTimes(6); + + await act(async () => { + resolveAnalysisRequests[5]?.(createAnalysisResponse("all-good")); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(document.body.textContent).toContain("All good"); + }); + expect(document.body.textContent).toContain("The query looks healthy."); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("does not present all-good analysis as a fix prompt", async () => { + useStudioMock.mockReturnValue({ + hasAiQueryRecommendations: true, + queryInsights: { + getSnapshot, + }, + requestLlm, + }); + requestLlm.mockResolvedValueOnce(createAnalysisResponse("all-good")); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + const allGoodButton = [...container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Good"), + ); + + expect(allGoodButton).not.toBeUndefined(); + expect(allGoodButton?.getAttribute("aria-label")).toBe( + "Open all good analysis details", + ); + expect(allGoodButton?.getAttribute("aria-label")).not.toContain("fix"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("surfaces AI analysis failures and retries them from the table action", async () => { + useStudioMock.mockReturnValue({ + hasAiQueryRecommendations: true, + queryInsights: { + getSnapshot, + }, + requestLlm, + }); + requestLlm + .mockRejectedValueOnce(new Error("AI provider unavailable")) + .mockResolvedValueOnce(createAnalysisResponse("warning")); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + await vi.waitFor(() => { + expect( + container.querySelector('[aria-label="Retry query analysis"]'), + ).not.toBeNull(); + }); + + expect(container.textContent).toContain("Analyze"); + + act(() => { + click(container.querySelector('[aria-label="Retry query analysis"]')!); + }); + await flushMicrotasks(); + + expect(requestLlm).toHaveBeenCalledTimes(2); + await vi.waitFor(() => { + expect( + container.querySelector( + '[aria-label="Open warning analysis for suggested fix and complete fix prompt"]', + ), + ).not.toBeNull(); + }); + expect(container.textContent).toContain("Warn"); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("only automatically analyzes query rows inside the selected activity window", async () => { + getSnapshot.mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_600_000, + pollingIntervalMs: 0, + queries: [ + { + ...createQuery("older-query", 1), + lastSeen: 1_779_963_000_000, + query: "select * from older_events", + tables: ["older_events"], + }, + { + ...createQuery("recent-query", 2), + lastSeen: 1_779_963_540_000, + query: "select * from recent_events", + tables: ["recent_events"], + }, + ], + }, + ]); + useStudioMock.mockReturnValue({ + hasAiQueryRecommendations: true, + queryInsights: { + getSnapshot, + }, + requestLlm, + }); + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + expect(requestLlm).toHaveBeenCalledTimes(1); + expect(requestLlm.mock.calls[0]?.[0].prompt).toContain( + "select * from recent_events", + ); + expect(requestLlm.mock.calls[0]?.[0].prompt).not.toContain( + "select * from older_events", + ); + + const fifteenMinuteButton = container.querySelector( + '[aria-label="Show the last 15m"]', + ); + + expect(fifteenMinuteButton).not.toBeNull(); + + act(() => { + click(fifteenMinuteButton!); + }); + await flushMicrotasks(); + + expect(requestLlm).toHaveBeenCalledTimes(2); + expect(requestLlm.mock.calls[1]?.[0].prompt).toContain( + "select * from older_events", + ); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + + it("does not start duplicate AI recommendation requests for the selected query while polling updates", async () => { + vi.useFakeTimers(); + getSnapshot.mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_200_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 3, + duration: 18, + id: "query-1", + lastSeen: 1_779_963_199_000, + query: "select * from users", + reads: 9, + rowsReturned: 3, + tables: ["users"], + }, + ], + }, + ]); + useStudioMock.mockReturnValue({ + hasAiQueryRecommendations: true, + queryInsights: { + getSnapshot, + }, + requestLlm, + }); + + let resolveAnalysis: (value: string) => void = () => {}; + requestLlm.mockReturnValue( + new Promise((resolve) => { + resolveAnalysis = resolve; + }), + ); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + await flushMicrotasks(); + + const rowButton = [...container.querySelectorAll("button")].find((button) => + button.textContent?.includes("select * from users"), + ); + + expect(rowButton).not.toBeUndefined(); + + act(() => { + click(rowButton!); + }); + await flushMicrotasks(); + + expect(requestLlm).toHaveBeenCalledTimes(1); + + getSnapshot.mockResolvedValueOnce([ + null, + { + generatedAt: 1_779_963_201_000, + pollingIntervalMs: 1000, + queries: [ + { + count: 4, + duration: 20, + id: "query-1", + lastSeen: 1_779_963_201_000, + query: "select * from users", + reads: 11, + rowsReturned: 3, + tables: ["users"], + }, + ], + }, + ]); + + await act(async () => { + vi.advanceTimersByTime(1000); + await Promise.resolve(); + }); + await flushMicrotasks(); + + expect(requestLlm).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveAnalysis(createAnalysisResponse()); + await Promise.resolve(); + }); + await vi.waitFor(() => { + expect(document.body.textContent).toContain( + "Project only the columns the UI needs.", + ); + }); + + act(() => { + root.unmount(); + }); + container.remove(); + vi.useRealTimers(); + }); +}); diff --git a/ui/studio/views/queries/QueriesView.tsx b/ui/studio/views/queries/QueriesView.tsx new file mode 100644 index 00000000..775da4dc --- /dev/null +++ b/ui/studio/views/queries/QueriesView.tsx @@ -0,0 +1,2488 @@ +import { + ChevronLeft, + ChevronRight, + CircleCheck, + Info, + Loader2, + Pause, + Play, + Sparkles, + TriangleAlert, +} from "lucide-react"; +import type { PointerEvent, ReactNode } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import type { + StudioQueryInsightQuery, + StudioQueryInsights, +} from "@/data/query-insights"; + +import { Badge } from "../../../components/ui/badge"; +import { Button } from "../../../components/ui/button"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../../components/ui/select"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "../../../components/ui/sheet"; +import { Skeleton } from "../../../components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../../components/ui/table"; +import { + ToggleGroup, + ToggleGroupItem, +} from "../../../components/ui/toggle-group"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../../../components/ui/tooltip"; +import { cn } from "../../../lib/utils"; +import { useStudio } from "../../context"; +import { StudioHeader } from "../../StudioHeader"; +import type { ViewProps } from "../View"; +import { + buildQueryInsightAnalysisPrompt, + parseQueryInsightAnalysisResponse, + type QueryInsightAnalysis, + type QueryInsightAnalysisLevel, +} from "./query-insights-ai"; + +const DEFAULT_QUERY_LIMIT = 500; +const DEFAULT_POLLING_INTERVAL_MS = 1000; +const ALL_TABLES_VALUE = "__all__"; +const AUTOMATIC_QUERY_ANALYSIS_LIMIT = 5; + +type SortField = "rowsReturned" | "latency" | "executions" | "lastSeen"; +type SortDirection = "asc" | "desc"; +type QueryActivityWindowSeconds = 60 | 300 | 900 | 3600; +type QueryActivitySampleKind = "context" | "measured"; + +interface SortState { + direction: SortDirection; + field: SortField; +} + +interface QueryActivitySample { + averageLatencyMs: number | null; + elapsedSeconds: number; + executionCount: number; + kind: QueryActivitySampleKind; + queriesPerSecond: number | null; + time: number; + totalDurationMs: number; +} + +interface QueryMetricSample { + averageLatencyMs: number; + elapsedSeconds: number; + executionCount: number; + kind: QueryActivitySampleKind; + query: StudioQueryInsightQuery; + reads: number; + rowsReturned: number; + time: number; + totalDurationMs: number; +} + +interface QueryActivityTotals { + count: number; + time: number; + totalDurationMs: number; +} + +interface QueryActivitySummary { + averageLatencyMs: number | null; + queriesPerSecond: number | null; +} + +interface QueryActivityCache { + pollingIntervalMs: number; + querySamples: QueryMetricSample[]; + queriesById: Map; + samples: QueryActivitySample[]; + totals: QueryActivityTotals | null; +} + +const DEFAULT_SORT: SortState = { + direction: "desc", + field: "rowsReturned", +}; + +const DEFAULT_QUERY_ACTIVITY_WINDOW_SECONDS = + 300 satisfies QueryActivityWindowSeconds; +const MAX_QUERY_ACTIVITY_WINDOW_SECONDS = + 3600 satisfies QueryActivityWindowSeconds; + +const QUERY_ACTIVITY_WINDOWS: Array<{ + label: string; + value: QueryActivityWindowSeconds; +}> = [ + { label: "1m", value: 60 }, + { label: "5m", value: 300 }, + { label: "15m", value: 900 }, + { label: "1h", value: 3600 }, +]; + +const SORT_OPTIONS: Array<{ + label: string; + value: `${SortField}:${SortDirection}`; +}> = [ + { label: "Rows returned high to low", value: "rowsReturned:desc" }, + { label: "Rows returned low to high", value: "rowsReturned:asc" }, + { label: "Latency high to low", value: "latency:desc" }, + { label: "Latency low to high", value: "latency:asc" }, + { label: "Executions high to low", value: "executions:desc" }, + { label: "Executions low to high", value: "executions:asc" }, + { label: "Last seen newest", value: "lastSeen:desc" }, + { label: "Last seen oldest", value: "lastSeen:asc" }, +]; + +const numberFormatter = new Intl.NumberFormat("en-US", { + maximumFractionDigits: 1, + notation: "compact", +}); + +const TOOLBAR_SELECT_TRIGGER_CLASS = + "h-7 min-w-0 rounded-md border-border/70 bg-muted/20 px-2.5 py-1 text-xs font-medium shadow-none transition-colors hover:bg-muted/30 data-[state=open]:border-border data-[state=open]:bg-background [&>div]:min-w-0 [&>div]:gap-0 [&>div]:overflow-hidden [&>div>span]:truncate"; + +const DEFAULT_QUERY_ACTIVITY_CHART_WIDTH = 640; +const MIN_QUERY_ACTIVITY_CHART_WIDTH = 320; +const QUERY_ACTIVITY_CHART_HEIGHT = 144; +const QUERY_ACTIVITY_CHART_PADDING = { + bottom: 24, + left: 28, + right: 12, + top: 10, +}; +const QUERY_ACTIVITY_CHART_PLOT_HEIGHT = + QUERY_ACTIVITY_CHART_HEIGHT - + QUERY_ACTIVITY_CHART_PADDING.top - + QUERY_ACTIVITY_CHART_PADDING.bottom; +const QUERY_ACTIVITY_GRID_LINES = [0, 0.25, 0.5, 0.75, 1] as const; +const QUERY_ACTIVITY_BUCKET_SECONDS = 1; +const QUERY_ACTIVITY_MAX_CONNECTED_GAP_SECONDS = 30; +const QUERY_ACTIVITY_SAMPLE_GAP_TOLERANCE_SECONDS = 2; +const QUERY_ACTIVITY_LATENCY_VISUAL_HEADROOM = 1.14; +const queryActivityCacheByProvider = new WeakMap< + StudioQueryInsights, + QueryActivityCache +>(); + +export function QueriesView(_props: ViewProps) { + const { hasAiQueryRecommendations, queryInsights, requestLlm } = useStudio(); + const queryActivityCache = useMemo( + () => (queryInsights ? getQueryActivityCache(queryInsights) : null), + [queryInsights], + ); + const [queries, setQueries] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isPaused, setIsPaused] = useState(false); + const [pollingIntervalMs, setPollingIntervalMs] = useState( + () => queryActivityCache?.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS, + ); + const [activityWindowSeconds, setActivityWindowSeconds] = + useState(DEFAULT_QUERY_ACTIVITY_WINDOW_SECONDS); + const [activitySamples, setActivitySamples] = useState( + () => queryActivityCache?.samples ?? [], + ); + const [queryMetricSamples, setQueryMetricSamples] = useState< + QueryMetricSample[] + >(() => queryActivityCache?.querySamples ?? []); + const [selectedTable, setSelectedTable] = useState(null); + const [sort, setSort] = useState(DEFAULT_SORT); + const [selectedQueryId, setSelectedQueryId] = useState(null); + const [analysisByQueryId, setAnalysisByQueryId] = useState< + Record + >({}); + const [analysisErrorByQueryId, setAnalysisErrorByQueryId] = useState< + Record + >({}); + const [analysisLoadingQueryId, setAnalysisLoadingQueryId] = useState< + string | null + >(null); + const [analysisQueuedQueryIds, setAnalysisQueuedQueryIds] = useState< + ReadonlySet + >(() => new Set()); + const analysisByQueryIdRef = useRef(analysisByQueryId); + const analysisQueueRef = useRef([]); + const analysisRunningRef = useRef(false); + const analysisScheduledQueryIdsRef = useRef(new Set()); + const automaticAnalysisCountRef = useRef(0); + const automaticallySeenQueryIdsRef = useRef(new Set()); + const isMountedRef = useRef(false); + const latestActivityTotalsRef = useRef( + queryActivityCache?.totals ?? null, + ); + const latestActivityQueriesRef = useRef>( + queryActivityCache?.queriesById ?? + new Map(), + ); + const queryActivityCacheRef = useRef( + queryActivityCache, + ); + const latestAbortControllerRef = useRef(null); + + const syncQueuedAnalysisIds = useCallback(() => { + if (!isMountedRef.current) { + return; + } + + setAnalysisQueuedQueryIds( + new Set(analysisQueueRef.current.map((query) => query.id)), + ); + }, []); + + const runNextQueryAnalysis = useCallback(() => { + if (analysisRunningRef.current || !hasAiQueryRecommendations) { + return; + } + + const query = analysisQueueRef.current.shift(); + + if (!query) { + syncQueuedAnalysisIds(); + return; + } + + syncQueuedAnalysisIds(); + analysisRunningRef.current = true; + setAnalysisLoadingQueryId(query.id); + setAnalysisErrorByQueryId((current) => ({ + ...current, + [query.id]: undefined, + })); + + void requestLlm({ + prompt: buildQueryInsightAnalysisPrompt(query), + task: "query-insights", + }) + .then((responseText) => { + if (!isMountedRef.current) { + return; + } + + const analysis = parseQueryInsightAnalysisResponse(responseText); + setAnalysisByQueryId((current) => { + const next = { + ...current, + [query.id]: analysis, + }; + analysisByQueryIdRef.current = next; + + return next; + }); + }) + .catch((error: unknown) => { + if (!isMountedRef.current) { + return; + } + + setAnalysisErrorByQueryId((current) => ({ + ...current, + [query.id]: error instanceof Error ? error.message : String(error), + })); + }) + .finally(() => { + analysisScheduledQueryIdsRef.current.delete(query.id); + analysisRunningRef.current = false; + + if (!isMountedRef.current) { + return; + } + + setAnalysisLoadingQueryId((current) => + current === query.id ? null : current, + ); + runNextQueryAnalysis(); + }); + }, [hasAiQueryRecommendations, requestLlm, syncQueuedAnalysisIds]); + + const enqueueQueryAnalysis = useCallback( + (query: StudioQueryInsightQuery): boolean => { + if (!hasAiQueryRecommendations) { + return false; + } + + if ( + analysisByQueryIdRef.current[query.id] || + analysisScheduledQueryIdsRef.current.has(query.id) + ) { + return false; + } + + analysisScheduledQueryIdsRef.current.add(query.id); + analysisQueueRef.current.push(query); + syncQueuedAnalysisIds(); + runNextQueryAnalysis(); + return true; + }, + [hasAiQueryRecommendations, runNextQueryAnalysis, syncQueuedAnalysisIds], + ); + + const fetchSnapshot = useCallback(async () => { + if (!queryInsights || isPaused) { + return; + } + + latestAbortControllerRef.current?.abort(); + const abortController = new AbortController(); + latestAbortControllerRef.current = abortController; + + try { + const [snapshotError, snapshot] = await queryInsights.getSnapshot( + { limit: DEFAULT_QUERY_LIMIT }, + { abortSignal: abortController.signal }, + ); + + if (abortController.signal.aborted) { + return; + } + + if (snapshotError) { + return; + } + + setQueries(snapshot.queries); + const nextPollingIntervalMs = + snapshot.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS; + const previousActivityTotals = latestActivityTotalsRef.current; + const activity = createQueryActivitySamples({ + previousQueriesById: latestActivityQueriesRef.current, + previousTotals: previousActivityTotals, + queries: snapshot.queries, + time: snapshot.generatedAt, + }); + const cache = queryActivityCacheRef.current; + const nextQueriesById = getQueriesById(snapshot.queries); + const didAdvanceActivity = activity.totals !== previousActivityTotals; + + if (didAdvanceActivity) { + latestActivityTotalsRef.current = activity.totals; + latestActivityQueriesRef.current = nextQueriesById; + if (cache) { + cache.queriesById = nextQueriesById; + cache.totals = activity.totals; + } + } + + setActivitySamples((current) => { + const nextSamples = appendQueryActivitySamples( + current, + activity.samples, + ); + + if (cache) { + cache.samples = nextSamples; + } + + return nextSamples; + }); + setQueryMetricSamples((current) => { + const nextSamples = appendQueryMetricSamples( + current, + activity.querySamples, + ); + + if (cache) { + cache.querySamples = nextSamples; + } + + return nextSamples; + }); + setPollingIntervalMs(nextPollingIntervalMs); + if (cache) { + cache.pollingIntervalMs = nextPollingIntervalMs; + } + } finally { + if (!abortController.signal.aborted) { + setIsLoading(false); + } + } + }, [isPaused, queryInsights]); + + useEffect(() => { + queryActivityCacheRef.current = queryActivityCache; + latestActivityTotalsRef.current = queryActivityCache?.totals ?? null; + latestActivityQueriesRef.current = + queryActivityCache?.queriesById ?? + new Map(); + setActivitySamples(queryActivityCache?.samples ?? []); + setQueryMetricSamples(queryActivityCache?.querySamples ?? []); + setPollingIntervalMs( + queryActivityCache?.pollingIntervalMs ?? DEFAULT_POLLING_INTERVAL_MS, + ); + }, [queryActivityCache]); + + useEffect(() => { + void fetchSnapshot(); + + return () => { + latestAbortControllerRef.current?.abort(); + }; + }, [fetchSnapshot]); + + useEffect(() => { + if (isPaused || pollingIntervalMs <= 0) { + return; + } + + const interval = setInterval(() => { + void fetchSnapshot(); + }, pollingIntervalMs); + + return () => clearInterval(interval); + }, [fetchSnapshot, isPaused, pollingIntervalMs]); + + useEffect(() => { + isMountedRef.current = true; + + return () => { + isMountedRef.current = false; + }; + }, []); + + useEffect(() => { + analysisByQueryIdRef.current = analysisByQueryId; + }, [analysisByQueryId]); + + useEffect(() => { + if (hasAiQueryRecommendations) { + return; + } + + analysisQueueRef.current = []; + analysisScheduledQueryIdsRef.current.clear(); + analysisRunningRef.current = false; + setAnalysisLoadingQueryId(null); + setAnalysisQueuedQueryIds(new Set()); + }, [hasAiQueryRecommendations]); + + const activityWindowRange = useMemo( + () => getQueryActivityWindowRange(activitySamples, activityWindowSeconds), + [activitySamples, activityWindowSeconds], + ); + const timeScopedQueries = useMemo( + () => + getWindowScopedQueries({ + queries, + querySamples: queryMetricSamples, + range: activityWindowRange, + }), + [activityWindowRange, queries, queryMetricSamples], + ); + + const availableTables = useMemo(() => { + const tables = new Set(); + + for (const query of timeScopedQueries) { + for (const table of query.tables) { + tables.add(table); + } + } + + return [...tables].sort((left, right) => left.localeCompare(right)); + }, [timeScopedQueries]); + + useEffect(() => { + if (selectedTable && !availableTables.includes(selectedTable)) { + setSelectedTable(null); + } + }, [availableTables, selectedTable]); + + const visibleQueries = useMemo(() => { + const filtered = selectedTable + ? timeScopedQueries.filter((query) => + query.tables.includes(selectedTable), + ) + : timeScopedQueries; + const multiplier = sort.direction === "desc" ? -1 : 1; + + return [...filtered].sort((left, right) => { + return ( + multiplier * + (getSortValue(left, sort.field) - getSortValue(right, sort.field)) + ); + }); + }, [selectedTable, sort, timeScopedQueries]); + + const selectedQuery = useMemo( + () => + visibleQueries.find((query) => query.id === selectedQueryId) ?? + timeScopedQueries.find((query) => query.id === selectedQueryId) ?? + null, + [selectedQueryId, timeScopedQueries, visibleQueries], + ); + const selectedQueryIndex = useMemo( + () => + selectedQueryId + ? visibleQueries.findIndex((query) => query.id === selectedQueryId) + : -1, + [selectedQueryId, visibleQueries], + ); + + useEffect(() => { + if (!hasAiQueryRecommendations) { + return; + } + + for (const query of timeScopedQueries) { + if (automaticallySeenQueryIdsRef.current.has(query.id)) { + continue; + } + + automaticallySeenQueryIdsRef.current.add(query.id); + + if (automaticAnalysisCountRef.current >= AUTOMATIC_QUERY_ANALYSIS_LIMIT) { + continue; + } + + if (enqueueQueryAnalysis(query)) { + automaticAnalysisCountRef.current += 1; + } + } + }, [enqueueQueryAnalysis, hasAiQueryRecommendations, timeScopedQueries]); + + useEffect(() => { + if (!selectedQuery || !hasAiQueryRecommendations) { + return; + } + + enqueueQueryAnalysis(selectedQuery); + }, [enqueueQueryAnalysis, hasAiQueryRecommendations, selectedQuery]); + + const selectPreviousQuery = useCallback(() => { + if (selectedQueryIndex <= 0) { + return; + } + + setSelectedQueryId(visibleQueries[selectedQueryIndex - 1]?.id ?? null); + }, [selectedQueryIndex, visibleQueries]); + + const selectNextQuery = useCallback(() => { + if ( + selectedQueryIndex < 0 || + selectedQueryIndex >= visibleQueries.length - 1 + ) { + return; + } + + setSelectedQueryId(visibleQueries[selectedQueryIndex + 1]?.id ?? null); + }, [selectedQueryIndex, visibleQueries]); + + if (!queryInsights) { + return ( +
+ +
+ Queries are not configured for this Studio embed. +
+
+ ); + } + + return ( +
+ + +
+
+
+

Queries

+

+ Monitor database activity and identify and fix poorly-performing + queries in your application.{" "} + + Find out how to see your Prisma ORM calls. + +

+
+ +
+ +
+
+ + + +
+
+ + + +
+
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( + + ))} +
+ ) : visibleQueries.length > 0 ? ( + + ) : ( +
+ {queries.length > 0 + ? "No query activity in this time range." + : "Waiting for query activity."} +
+ )} +
+
+ + = 0 && + selectedQueryIndex < visibleQueries.length - 1 + } + hasPrevious={selectedQueryIndex > 0} + isAnalysisLoading={analysisLoadingQueryId === selectedQuery?.id} + isAnalysisQueued={ + selectedQuery ? analysisQueuedQueryIds.has(selectedQuery.id) : false + } + onClose={() => setSelectedQueryId(null)} + onAnalyze={() => { + if (selectedQuery) { + enqueueQueryAnalysis(selectedQuery); + } + }} + onNext={selectNextQuery} + onPrevious={selectPreviousQuery} + query={selectedQuery} + /> +
+ ); +} + +function ToolbarSelectControl(props: { children: ReactNode; label: string }) { + return ( +
+ + {props.label} + + {props.children} +
+ ); +} + +function QueryActivityChart(props: { + onWindowChange: (windowSeconds: QueryActivityWindowSeconds) => void; + samples: QueryActivitySample[]; + windowSeconds: QueryActivityWindowSeconds; +}) { + const { onWindowChange, samples, windowSeconds } = props; + const chartFrameRef = useRef(null); + const [chartWidth, setChartWidth] = useState( + DEFAULT_QUERY_ACTIVITY_CHART_WIDTH, + ); + const [hoveredSampleTime, setHoveredSampleTime] = useState( + null, + ); + const visibleSamples = getVisibleActivitySamples(samples, windowSeconds); + const activitySummary = getQueryActivitySummary(visibleSamples); + const activityWindowRange = getQueryActivityWindowRange( + samples, + windowSeconds, + ); + const domainStart = activityWindowRange.start; + const domainEnd = activityWindowRange.end; + const chartPlotWidth = getQueryActivityPlotWidth(chartWidth); + const queriesPerSecondMax = getSeriesMax( + visibleSamples, + (sample) => sample.queriesPerSecond, + ); + const latencyMax = + getSeriesMax(visibleSamples, (sample) => sample.averageLatencyMs) * + QUERY_ACTIVITY_LATENCY_VISUAL_HEADROOM; + const queriesPerSecondPath = buildQueryActivityPath({ + chartWidth, + domainEnd, + domainStart, + getValue: (sample) => sample.queriesPerSecond, + maxValue: queriesPerSecondMax, + samples: visibleSamples, + }); + const latencyPath = buildQueryActivityPath({ + chartWidth, + domainEnd, + domainStart, + getValue: (sample) => sample.averageLatencyMs, + maxValue: latencyMax, + samples: visibleSamples, + }); + const isolatedSamples = getIsolatedQueryActivitySamples(visibleSamples); + const xTicks = getQueryActivityTicks(domainStart, domainEnd); + const hasVisibleActivity = visibleSamples.some(hasQueryActivitySampleValue); + const hoveredSample = + hoveredSampleTime === null + ? null + : (visibleSamples.find((sample) => sample.time === hoveredSampleTime) ?? + null); + const hoveredX = hoveredSample + ? getQueryActivityX({ + chartWidth, + domainEnd, + domainStart, + time: hoveredSample.time, + }) + : null; + const hoveredQueriesPerSecondY = hoveredSample + ? getNullableQueryActivityY( + hoveredSample.queriesPerSecond, + queriesPerSecondMax, + ) + : null; + const hoveredLatencyY = hoveredSample + ? getNullableQueryActivityY(hoveredSample.averageLatencyMs, latencyMax) + : null; + const tooltipTransform = + hoveredX === null + ? "translateX(-50%)" + : hoveredX < 96 + ? "translateX(0)" + : hoveredX > chartWidth - 96 + ? "translateX(-100%)" + : "translateX(-50%)"; + const handleChartPointerMove = (event: PointerEvent) => { + const hoverableSamples = visibleSamples.filter(hasQueryActivitySampleValue); + + if (hoverableSamples.length === 0) { + setHoveredSampleTime(null); + return; + } + + const bounds = event.currentTarget.getBoundingClientRect(); + const pointerX = event.clientX - bounds.left; + const pointerProgress = Math.min( + 1, + Math.max( + 0, + (pointerX - QUERY_ACTIVITY_CHART_PADDING.left) / chartPlotWidth, + ), + ); + const pointerTime = + domainStart + pointerProgress * (domainEnd - domainStart); + const nearestSample = hoverableSamples.reduce((nearest, sample) => { + return Math.abs(sample.time - pointerTime) < + Math.abs(nearest.time - pointerTime) + ? sample + : nearest; + }); + + setHoveredSampleTime(nearestSample.time); + }; + + useEffect(() => { + const element = chartFrameRef.current; + + if (!element || typeof ResizeObserver === "undefined") { + return; + } + + const observer = new ResizeObserver(([entry]) => { + const nextWidth = Math.max( + MIN_QUERY_ACTIVITY_CHART_WIDTH, + Math.round(entry?.contentRect.width ?? 0), + ); + + setChartWidth(nextWidth); + }); + + observer.observe(element); + + return () => observer.disconnect(); + }, []); + + return ( +
+
+
+ + +
+ + { + const nextWindow = Number(value); + + if (isQueryActivityWindowSeconds(nextWindow)) { + onWindowChange(nextWindow); + } + }} + type="single" + value={String(windowSeconds)} + > + {QUERY_ACTIVITY_WINDOWS.map((windowOption) => ( + + {windowOption.label} + + ))} + +
+ +
setHoveredSampleTime(null)} + onPointerMove={handleChartPointerMove} + ref={chartFrameRef} + > + + {hasVisibleActivity && ( + <> + {QUERY_ACTIVITY_GRID_LINES.map((line) => { + const y = + QUERY_ACTIVITY_CHART_PADDING.top + + line * QUERY_ACTIVITY_CHART_PLOT_HEIGHT; + + return ( + + ); + })} + {xTicks.map((tick) => { + const x = getQueryActivityX({ + chartWidth, + domainEnd, + domainStart, + time: tick, + }); + + return ( + + + + {formatQueryActivityTick(tick, domainEnd)} + + + ); + })} + + + {isolatedSamples.map((sample) => { + const x = getQueryActivityX({ + chartWidth, + domainEnd, + domainStart, + time: sample.time, + }); + const queriesPerSecondY = getNullableQueryActivityY( + sample.queriesPerSecond, + queriesPerSecondMax, + ); + const latencyY = getNullableQueryActivityY( + sample.averageLatencyMs, + latencyMax, + ); + + return ( + + {sample.queriesPerSecond !== null && + sample.queriesPerSecond > 0 && + queriesPerSecondY !== null && ( + + )} + {sample.averageLatencyMs !== null && + sample.averageLatencyMs > 0 && + latencyY !== null && ( + + )} + + ); + })} + + )} + {hoveredSample && + hoveredX !== null && + (hoveredQueriesPerSecondY !== null || hoveredLatencyY !== null) && ( + + + {hoveredQueriesPerSecondY !== null && ( + + )} + {hoveredLatencyY !== null && ( + + )} + + )} + + + {hoveredSample && hoveredX !== null && ( +
+
+ {formatTime(hoveredSample.time * 1000)} +
+ + +
+ )} + + {!hasVisibleActivity && ( +
+ Waiting for query activity +
+ )} +
+
+ ); +} + +function QueryActivityTooltipMetric(props: { + colorClassName: string; + label: string; + testId: string; + value: string; + valueClassName: string; +}) { + return ( +
+ + + {props.label} + + + {props.value} + +
+ ); +} + +function QueryActivityLegend(props: { + colorClassName: string; + label: string; + value: string; +}) { + return ( +
+ + {props.label} + {props.value} +
+ ); +} + +function QueryTable(props: { + analysisByQueryId: Record; + analysisErrorByQueryId: Record; + analysisLoadingQueryId: string | null; + analysisQueuedQueryIds: ReadonlySet; + canShowAnalysis: boolean; + onAnalyzeQuery: (query: StudioQueryInsightQuery) => void; + onSelectQuery: (queryId: string) => void; + queries: StudioQueryInsightQuery[]; + selectedQueryId: string | null; + sortField: SortField; +}) { + const { + analysisByQueryId, + analysisErrorByQueryId, + analysisLoadingQueryId, + analysisQueuedQueryIds, + canShowAnalysis, + onAnalyzeQuery, + onSelectQuery, + queries, + selectedQueryId, + sortField, + } = props; + + return ( + + + + + + + + + {canShowAnalysis && } + + + + + Latency + + + Query + + + + Executions + + + + + Rows Returned + + + + Last Seen + + {canShowAnalysis && ( + + Analysis + + )} + + + + {queries.map((query) => ( + + + + + + + + + + {numberFormatter.format(query.count)} + + + + {numberFormatter.format(query.rowsReturned)} + + + {formatTime(query.lastSeen)} + + {canShowAnalysis && ( + + + + )} + + ))} + +
+
+ ); +} + +function QueryAnalysisCell(props: { + analysis?: QueryInsightAnalysis; + error?: string; + isLoading: boolean; + isQueued: boolean; + onAnalyzeQuery: (query: StudioQueryInsightQuery) => void; + onOpenQuery: (queryId: string) => void; + query: StudioQueryInsightQuery; +}) { + const { + analysis, + error, + isLoading, + isQueued, + onAnalyzeQuery, + onOpenQuery, + query, + } = props; + + if (isLoading) { + return ( + + + + ); + } + + if (analysis) { + const metadata = getQueryAnalysisLevelMetadata(analysis.level); + + return ( + + + + + +
+ {metadata.label}: {analysis.summary} +
+
{metadata.tooltipAction}
+
+
+ ); + } + + if (isQueued) { + return ( + + + Queued + + ); + } + + return ( + + ); +} + +function QueryAnalysisLevelIcon(props: { level: QueryInsightAnalysisLevel }) { + if (props.level === "warning") { + return ; + } + + if (props.level === "info") { + return ; + } + + return ; +} + +function getQueryAnalysisLevelMetadata(level: QueryInsightAnalysisLevel): { + actionAriaLabel: string; + badgeClassName: string; + interactiveClassName: string; + label: string; + shortLabel: string; + tooltipAction: string; +} { + if (level === "warning") { + return { + actionAriaLabel: + "Open warning analysis for suggested fix and complete fix prompt", + badgeClassName: + "border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-300", + interactiveClassName: + "border-amber-500/25 bg-amber-500/10 text-amber-700 hover:bg-amber-500/15 hover:text-amber-800 dark:text-amber-300", + label: "Warning", + shortLabel: "Warn", + tooltipAction: "Open for a suggested fix and complete fix prompt.", + }; + } + + if (level === "info") { + return { + actionAriaLabel: + "Open info analysis for suggested fix and complete fix prompt", + badgeClassName: + "border-sky-500/20 bg-sky-500/10 text-sky-700 dark:text-sky-300", + interactiveClassName: + "border-sky-500/20 bg-sky-500/10 text-sky-700 hover:bg-sky-500/15 hover:text-sky-800 dark:text-sky-300", + label: "Info", + shortLabel: "Info", + tooltipAction: "Open for a suggested fix and complete fix prompt.", + }; + } + + return { + actionAriaLabel: "Open all good analysis details", + badgeClassName: + "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300", + interactiveClassName: + "border-emerald-500/20 bg-emerald-500/10 text-emerald-700 hover:bg-emerald-500/15 hover:text-emerald-800 dark:text-emerald-300", + label: "All good", + shortLabel: "Good", + tooltipAction: "Open to review why this query looks healthy.", + }; +} + +function SortLabel(props: { active: boolean; children: string }) { + return ( + + {props.children} + + ); +} + +function LatencyBadge(props: { duration: number }) { + const variant = + props.duration >= 500 + ? "destructive" + : props.duration >= 100 + ? "outline" + : "success"; + + return {formatLatency(props.duration)}; +} + +function QueryDetailsSheet(props: { + analysis?: QueryInsightAnalysis; + analysisError?: string; + canShowAnalysis: boolean; + hasNext: boolean; + hasPrevious: boolean; + isAnalysisLoading: boolean; + isAnalysisQueued: boolean; + onAnalyze: () => void; + onClose: () => void; + onNext: () => void; + onPrevious: () => void; + query: StudioQueryInsightQuery | null; +}) { + const { + analysis, + analysisError, + canShowAnalysis, + hasNext, + hasPrevious, + isAnalysisLoading, + isAnalysisQueued, + onAnalyze, + onClose, + onNext, + onPrevious, + query, + } = props; + + return ( + !open && onClose()}> + + {query && ( + <> + +
+ + +
+ Query Details + + {numberFormatter.format(query.count)} executions, average{" "} + {formatLatency(query.duration)} latency. + +
+ +
+
+
+ {query.tables.map((table) => ( + + {table} + + ))} +
+ +
+                  {query.query}
+                
+ +
+ + + +
+ + {canShowAnalysis && ( +
+
+
+ + Recommendations +
+ {analysis && ( + + )} + {!analysis && !isAnalysisLoading && !isAnalysisQueued && ( + + )} +
+ + {isAnalysisLoading ? ( +
+ + + +
+ ) : isAnalysisQueued ? ( +

+ Waiting for the current analysis to finish. +

+ ) : analysisError ? ( +

+ {analysisError} +

+ ) : analysis ? ( + + ) : null} +
+ )} +
+
+ + )} +
+
+ ); +} + +function MetricInline(props: { label: string; value: number | string }) { + return ( +
+
{props.label}
+
+ {typeof props.value === "number" + ? numberFormatter.format(props.value) + : props.value} +
+
+ ); +} + +function QueryAnalysisStatusBadge(props: { level: QueryInsightAnalysisLevel }) { + const metadata = getQueryAnalysisLevelMetadata(props.level); + + return ( + + + {metadata.label} + + ); +} + +function QueryAnalysis(props: { analysis: QueryInsightAnalysis }) { + const { analysis } = props; + + return ( +
+

{analysis.summary}

+ + {analysis.recommendations.length > 0 && ( +
    + {analysis.recommendations.map((recommendation) => ( +
  • {recommendation}
  • + ))} +
+ )} + + {analysis.improvedSql && ( + + )} + {analysis.improvedPrisma && ( + + )} +
+ ); +} + +function CodeSuggestion(props: { label: string; value: string }) { + return ( +
+
+ {props.label} +
+
+        {props.value}
+      
+
+ ); +} + +function createQueryActivitySamples(args: { + previousQueriesById: Map; + previousTotals: QueryActivityTotals | null; + queries: StudioQueryInsightQuery[]; + time: number; +}): { + querySamples: QueryMetricSample[]; + samples: QueryActivitySample[]; + totals: QueryActivityTotals; +} { + const { previousQueriesById, previousTotals, queries, time } = args; + const count = queries.reduce((sum, query) => sum + query.count, 0); + const totalDurationMs = queries.reduce( + (sum, query) => sum + query.duration * query.count, + 0, + ); + const totals = { + count, + time, + totalDurationMs, + }; + + if (!previousTotals) { + const seededSamples = createInitialQueryActivitySamples({ + queries, + time, + }); + const seededQuerySamples = createInitialQueryMetricSamples({ + queries, + time, + }); + + return { + querySamples: seededQuerySamples, + samples: seededSamples, + totals, + }; + } + + const elapsedSeconds = (time - previousTotals.time) / 1000; + if (elapsedSeconds <= 0) { + return { + querySamples: [], + samples: [], + totals: previousTotals, + }; + } + + const querySamples = createQueryMetricSamples({ + elapsedSeconds, + previousQueriesById, + previousTotals, + queries, + time, + }); + return { + querySamples, + samples: createMeasuredQueryActivitySamples({ + elapsedSeconds, + querySamples, + time, + }), + totals, + }; +} + +function createMeasuredQueryActivitySamples(args: { + elapsedSeconds: number; + querySamples: QueryMetricSample[]; + time: number; +}): QueryActivitySample[] { + const { elapsedSeconds, querySamples, time } = args; + const samplesByTime = new Map(); + const snapshotTime = time / 1000; + + for (const querySample of querySamples) { + const existingSample = samplesByTime.get(querySample.time); + const executionCount = + (existingSample?.executionCount ?? 0) + querySample.executionCount; + const totalDurationMs = + (existingSample?.totalDurationMs ?? 0) + querySample.totalDurationMs; + + samplesByTime.set(querySample.time, { + averageLatencyMs: + executionCount > 0 ? totalDurationMs / executionCount : 0, + elapsedSeconds: existingSample?.elapsedSeconds ?? 0, + executionCount, + kind: "measured", + queriesPerSecond: executionCount / QUERY_ACTIVITY_BUCKET_SECONDS, + time: querySample.time, + totalDurationMs, + }); + } + + const existingSnapshotSample = samplesByTime.get(snapshotTime); + + samplesByTime.set(snapshotTime, { + averageLatencyMs: existingSnapshotSample?.averageLatencyMs ?? 0, + elapsedSeconds: + (existingSnapshotSample?.elapsedSeconds ?? 0) + elapsedSeconds, + executionCount: existingSnapshotSample?.executionCount ?? 0, + kind: "measured", + queriesPerSecond: existingSnapshotSample?.queriesPerSecond ?? 0, + time: snapshotTime, + totalDurationMs: existingSnapshotSample?.totalDurationMs ?? 0, + }); + + return [...samplesByTime.values()].sort( + (left, right) => left.time - right.time, + ); +} + +function createQueryMetricSamples(args: { + elapsedSeconds: number; + previousQueriesById: Map; + previousTotals: QueryActivityTotals; + queries: StudioQueryInsightQuery[]; + time: number; +}): QueryMetricSample[] { + const { elapsedSeconds, previousQueriesById, previousTotals, queries, time } = + args; + + return queries.flatMap((query) => { + const previousQuery = previousQueriesById.get(query.id); + const hasRecentExecution = query.lastSeen >= previousTotals.time; + const countersAreContinuous = + previousQuery !== undefined && + query.count >= previousQuery.count && + query.rowsReturned >= previousQuery.rowsReturned && + query.reads >= previousQuery.reads; + const executionCount = countersAreContinuous + ? query.count - previousQuery.count + : hasRecentExecution + ? query.count + : 0; + + if (executionCount <= 0) { + return []; + } + + const rowsReturned = countersAreContinuous + ? query.rowsReturned - previousQuery.rowsReturned + : query.rowsReturned; + const reads = countersAreContinuous + ? query.reads - previousQuery.reads + : query.reads; + const totalDurationMs = countersAreContinuous + ? Math.max( + 0, + query.duration * query.count - + previousQuery.duration * previousQuery.count, + ) + : query.duration * executionCount; + + return [ + { + averageLatencyMs: + executionCount > 0 ? totalDurationMs / executionCount : 0, + elapsedSeconds, + executionCount, + kind: "measured", + query, + reads, + rowsReturned, + time: getMeasuredQuerySampleTime({ + lastSeen: query.lastSeen, + previousTime: previousTotals.time, + time, + }), + totalDurationMs, + }, + ]; + }); +} + +function getMeasuredQuerySampleTime(args: { + lastSeen: number; + previousTime: number; + time: number; +}): number { + const { lastSeen, previousTime, time } = args; + const observedTime = Number.isFinite(lastSeen) ? lastSeen : time; + const clampedObservedTime = Math.min( + time, + Math.max(previousTime, observedTime), + ); + + return Math.floor(clampedObservedTime / 1000); +} + +function createInitialQueryActivitySamples(args: { + queries: StudioQueryInsightQuery[]; + time: number; +}): QueryActivitySample[] { + const { queries, time } = args; + const snapshotTime = time / 1000; + const cutoff = snapshotTime - MAX_QUERY_ACTIVITY_WINDOW_SECONDS; + const samplesByTime = new Map(); + + for (const query of queries) { + const sampleTime = Math.floor(query.lastSeen / 1000); + + if (sampleTime < cutoff || query.count <= 0) { + continue; + } + + const existingSample = samplesByTime.get(sampleTime); + const executionCount = 1; + const totalDurationMs = query.duration; + const nextExecutionCount = + (existingSample?.executionCount ?? 0) + executionCount; + const nextTotalDurationMs = + (existingSample?.totalDurationMs ?? 0) + totalDurationMs; + + samplesByTime.set(sampleTime, { + averageLatencyMs: + nextExecutionCount > 0 ? nextTotalDurationMs / nextExecutionCount : 0, + elapsedSeconds: 0, + executionCount: nextExecutionCount, + kind: "context", + queriesPerSecond: null, + time: sampleTime, + totalDurationMs: nextTotalDurationMs, + }); + } + + const samples = [...samplesByTime.values()].sort( + (left, right) => left.time - right.time, + ); + + if (samples.at(-1)?.time !== snapshotTime) { + samples.push({ + averageLatencyMs: null, + elapsedSeconds: 0, + executionCount: 0, + kind: "context", + queriesPerSecond: null, + time: snapshotTime, + totalDurationMs: 0, + }); + } + + return samples; +} + +function createInitialQueryMetricSamples(args: { + queries: StudioQueryInsightQuery[]; + time: number; +}): QueryMetricSample[] { + const { queries, time } = args; + const snapshotTime = time / 1000; + const cutoff = snapshotTime - MAX_QUERY_ACTIVITY_WINDOW_SECONDS; + + return queries + .flatMap((query) => { + const sampleTime = Math.floor(query.lastSeen / 1000); + + if (sampleTime < cutoff || query.count <= 0) { + return []; + } + + return [ + { + averageLatencyMs: query.duration, + elapsedSeconds: 0, + executionCount: 1, + kind: "context", + query, + reads: getAverageCounterPerExecution(query.reads, query.count), + rowsReturned: getAverageCounterPerExecution( + query.rowsReturned, + query.count, + ), + time: sampleTime, + totalDurationMs: query.duration, + }, + ] satisfies QueryMetricSample[]; + }) + .sort((left, right) => left.time - right.time); +} + +function appendQueryActivitySamples( + samples: QueryActivitySample[], + nextSamples: QueryActivitySample[], +): QueryActivitySample[] { + if (nextSamples.length === 0) { + return samples; + } + + const samplesByTime = new Map(); + + for (const sample of samples) { + samplesByTime.set(sample.time, sample); + } + + for (const sample of nextSamples) { + const existingSample = samplesByTime.get(sample.time); + samplesByTime.set( + sample.time, + existingSample + ? mergeQueryActivitySamples(existingSample, sample) + : sample, + ); + } + + const latestTime = + nextSamples.at(-1)?.time ?? samples.at(-1)?.time ?? Date.now() / 1000; + const cutoff = latestTime - MAX_QUERY_ACTIVITY_WINDOW_SECONDS; + + return [...samplesByTime.values()] + .filter((sample) => sample.time >= cutoff) + .sort((left, right) => left.time - right.time); +} + +function mergeQueryActivitySamples( + existingSample: QueryActivitySample, + nextSample: QueryActivitySample, +): QueryActivitySample { + const executionCount = + existingSample.executionCount + nextSample.executionCount; + const totalDurationMs = + existingSample.totalDurationMs + nextSample.totalDurationMs; + const elapsedSeconds = + existingSample.elapsedSeconds + nextSample.elapsedSeconds; + const kind = + existingSample.kind === "measured" || nextSample.kind === "measured" + ? "measured" + : "context"; + + return { + averageLatencyMs: executionCount > 0 ? totalDurationMs / executionCount : 0, + elapsedSeconds, + executionCount, + kind, + queriesPerSecond: + kind === "measured" + ? executionCount / QUERY_ACTIVITY_BUCKET_SECONDS + : null, + time: nextSample.time, + totalDurationMs, + }; +} + +function appendQueryMetricSamples( + samples: QueryMetricSample[], + nextSamples: QueryMetricSample[], +): QueryMetricSample[] { + if (nextSamples.length === 0) { + return samples; + } + + const latestTime = + nextSamples.at(-1)?.time ?? samples.at(-1)?.time ?? Date.now() / 1000; + const cutoff = latestTime - MAX_QUERY_ACTIVITY_WINDOW_SECONDS; + + return [...samples, ...nextSamples] + .filter((sample) => sample.time >= cutoff) + .sort((left, right) => { + if (left.time !== right.time) { + return left.time - right.time; + } + + return left.query.id.localeCompare(right.query.id); + }); +} + +function getVisibleActivitySamples( + samples: QueryActivitySample[], + windowSeconds: QueryActivityWindowSeconds, +): QueryActivitySample[] { + const range = getQueryActivityWindowRange(samples, windowSeconds); + + return samples.filter( + (sample) => sample.time >= range.start && sample.time <= range.end, + ); +} + +function getQueryActivityWindowRange( + samples: QueryActivitySample[], + windowSeconds: QueryActivityWindowSeconds, +): { end: number; start: number } { + const end = samples.at(-1)?.time ?? Date.now() / 1000; + + return { + end, + start: end - windowSeconds, + }; +} + +function getWindowScopedQueries(args: { + queries: StudioQueryInsightQuery[]; + querySamples: QueryMetricSample[]; + range: { end: number; start: number }; +}): StudioQueryInsightQuery[] { + const { queries, querySamples, range } = args; + const latestQueriesById = getQueriesById(queries); + const samplesByQueryId = new Map(); + + for (const sample of querySamples) { + if ( + sample.executionCount <= 0 || + sample.time < range.start || + sample.time > range.end + ) { + continue; + } + + const samples = samplesByQueryId.get(sample.query.id) ?? []; + samples.push(sample); + samplesByQueryId.set(sample.query.id, samples); + } + + return [...samplesByQueryId.entries()].map(([queryId, samples]) => { + const latestSample = samples.at(-1); + const baseQuery = + latestQueriesById.get(queryId) ?? latestSample?.query ?? null; + + if (!baseQuery) { + throw new Error(`Missing query for Query Insights sample: ${queryId}`); + } + + const count = samples.reduce( + (sum, sample) => sum + sample.executionCount, + 0, + ); + const totalDurationMs = samples.reduce( + (sum, sample) => sum + sample.totalDurationMs, + 0, + ); + + return { + ...baseQuery, + count, + duration: count > 0 ? totalDurationMs / count : baseQuery.duration, + lastSeen: Math.max(...samples.map((sample) => sample.query.lastSeen)), + reads: samples.reduce((sum, sample) => sum + sample.reads, 0), + rowsReturned: samples.reduce( + (sum, sample) => sum + sample.rowsReturned, + 0, + ), + }; + }); +} + +function getQueriesById( + queries: StudioQueryInsightQuery[], +): Map { + return new Map(queries.map((query) => [query.id, query])); +} + +function getAverageCounterPerExecution(total: number, count: number): number { + if (!Number.isFinite(total) || count <= 0) { + return 0; + } + + return Math.max(0, Math.round(total / count)); +} + +function getSeriesMax( + samples: QueryActivitySample[], + getValue: (sample: QueryActivitySample) => number | null, +): number { + const values = samples + .map(getValue) + .filter( + (value): value is number => value !== null && Number.isFinite(value), + ); + const max = Math.max(0, ...values); + + return max > 0 ? max : 1; +} + +function getQueryActivitySummary( + samples: QueryActivitySample[], +): QueryActivitySummary { + const measuredSamples = samples.filter(isMeasuredQueryActivitySample); + const contextSamples = samples.filter( + (sample) => sample.kind === "context" && sample.executionCount > 0, + ); + const measuredSeconds = measuredSamples.reduce( + (sum, sample) => sum + sample.elapsedSeconds, + 0, + ); + const executionCount = measuredSamples.reduce( + (sum, sample) => sum + sample.executionCount, + 0, + ); + const totalDurationMs = measuredSamples.reduce( + (sum, sample) => sum + sample.totalDurationMs, + 0, + ); + const contextExecutionCount = contextSamples.reduce( + (sum, sample) => sum + sample.executionCount, + 0, + ); + const contextDurationMs = contextSamples.reduce( + (sum, sample) => sum + sample.totalDurationMs, + 0, + ); + + return { + averageLatencyMs: + executionCount > 0 + ? totalDurationMs / executionCount + : contextExecutionCount > 0 + ? contextDurationMs / contextExecutionCount + : null, + queriesPerSecond: + measuredSeconds > 0 ? executionCount / measuredSeconds : null, + }; +} + +function buildQueryActivityPath(args: { + chartWidth: number; + domainEnd: number; + domainStart: number; + getValue: (sample: QueryActivitySample) => number | null; + maxValue: number; + samples: QueryActivitySample[]; +}): string { + const { chartWidth, domainEnd, domainStart, getValue, maxValue, samples } = + args; + const measuredSamples = samples.filter((sample) => { + return isMeasuredQueryActivitySample(sample) && getValue(sample) !== null; + }); + + if (measuredSamples.length === 0) { + return ""; + } + + let previousSample: QueryActivitySample | null = null; + + return measuredSamples + .map((sample) => { + const x = getQueryActivityX({ + chartWidth, + domainEnd, + domainStart, + time: sample.time, + }); + const value = getValue(sample); + + if (value === null) { + return ""; + } + + const y = getQueryActivityY(value, maxValue); + const command = + previousSample && + areQueryActivitySamplesContinuous(previousSample, sample) + ? "L" + : "M"; + + previousSample = sample; + + return `${command}${x.toFixed(2)},${y.toFixed(2)}`; + }) + .join(" "); +} + +function areQueryActivitySamplesContinuous( + previousSample: QueryActivitySample, + nextSample: QueryActivitySample, +): boolean { + if ( + !isMeasuredQueryActivitySample(previousSample) || + !isMeasuredQueryActivitySample(nextSample) + ) { + return false; + } + + const gapSeconds = nextSample.time - previousSample.time; + + return ( + gapSeconds <= + QUERY_ACTIVITY_MAX_CONNECTED_GAP_SECONDS + + QUERY_ACTIVITY_SAMPLE_GAP_TOLERANCE_SECONDS + ); +} + +function getIsolatedQueryActivitySamples( + samples: QueryActivitySample[], +): QueryActivitySample[] { + const activitySamples = samples.filter(hasQueryActivitySampleValue); + + return activitySamples.filter((sample, index) => { + const previousSample = activitySamples[index - 1]; + const nextSample = activitySamples[index + 1]; + const isConnectedToPrevious = + previousSample && + areQueryActivitySamplesContinuous(previousSample, sample); + const isConnectedToNext = + nextSample && areQueryActivitySamplesContinuous(sample, nextSample); + + return !isConnectedToPrevious && !isConnectedToNext; + }); +} + +function getQueryActivityCache( + queryInsights: StudioQueryInsights, +): QueryActivityCache { + const existingCache = queryActivityCacheByProvider.get(queryInsights); + + if (existingCache) { + return existingCache; + } + + const cache = { + pollingIntervalMs: DEFAULT_POLLING_INTERVAL_MS, + queriesById: new Map(), + querySamples: [], + samples: [], + totals: null, + }; + + queryActivityCacheByProvider.set(queryInsights, cache); + + return cache; +} + +function isMeasuredQueryActivitySample(sample: QueryActivitySample): boolean { + return sample.kind === "measured"; +} + +function hasQueryActivitySampleValue(sample: QueryActivitySample): boolean { + return ( + (sample.queriesPerSecond !== null && sample.queriesPerSecond > 0) || + (sample.averageLatencyMs !== null && sample.averageLatencyMs > 0) + ); +} + +function getQueryActivityX(args: { + chartWidth: number; + domainEnd: number; + domainStart: number; + time: number; +}): number { + const { chartWidth, domainEnd, domainStart, time } = args; + const domainWidth = Math.max(1, domainEnd - domainStart); + const progress = Math.min(1, Math.max(0, (time - domainStart) / domainWidth)); + + return ( + QUERY_ACTIVITY_CHART_PADDING.left + + progress * getQueryActivityPlotWidth(chartWidth) + ); +} + +function getQueryActivityPlotWidth(chartWidth: number): number { + return Math.max( + 1, + chartWidth - + QUERY_ACTIVITY_CHART_PADDING.left - + QUERY_ACTIVITY_CHART_PADDING.right, + ); +} + +function getQueryActivityY(value: number, maxValue: number): number { + const progress = Math.min(1, Math.max(0, value / Math.max(1, maxValue))); + + return ( + QUERY_ACTIVITY_CHART_PADDING.top + + (1 - progress) * QUERY_ACTIVITY_CHART_PLOT_HEIGHT + ); +} + +function getNullableQueryActivityY( + value: number | null, + maxValue: number, +): number | null { + return value === null ? null : getQueryActivityY(value, maxValue); +} + +function getQueryActivityTicks(domainStart: number, domainEnd: number) { + const tickCount = 5; + const step = (domainEnd - domainStart) / (tickCount - 1); + + return Array.from({ length: tickCount }, (_, index) => + index === tickCount - 1 ? domainEnd : domainStart + step * index, + ); +} + +function formatQueryActivityTick(tick: number, domainEnd: number): string { + const secondsAgo = Math.max(0, domainEnd - tick); + + if (secondsAgo < 1) { + return "now"; + } + + if (secondsAgo < 60) { + return `-${secondsAgo.toFixed(0)}s`; + } + + if (secondsAgo < 3600) { + return `-${Math.round(secondsAgo / 60)}m`; + } + + return `-${Math.round(secondsAgo / 3600)}h`; +} + +function formatQueriesPerSecond(value: number | null): string { + if (value === null || !Number.isFinite(value)) { + return "n/a"; + } + + if (value <= 0) { + return "0/s"; + } + + if (value < 0.01) { + return "< 0.01/s"; + } + + if (value < 1) { + return `${value.toFixed(2)}/s`; + } + + return `${value.toFixed(value >= 10 ? 0 : 1)}/s`; +} + +function formatActivityLatency(value: number | null): string { + if (value === null || !Number.isFinite(value)) { + return "n/a"; + } + + if (value <= 0) { + return "0ms"; + } + + return formatLatency(value); +} + +function isQueryActivityWindowSeconds( + value: number, +): value is QueryActivityWindowSeconds { + return QUERY_ACTIVITY_WINDOWS.some((option) => option.value === value); +} + +function getSortValue( + query: StudioQueryInsightQuery, + field: SortField, +): number { + switch (field) { + case "executions": + return query.count; + case "lastSeen": + return query.lastSeen; + case "latency": + return query.duration; + case "rowsReturned": + return query.rowsReturned; + } +} + +function formatLatency(durationMs: number): string { + if (!Number.isFinite(durationMs)) { + return "n/a"; + } + + if (durationMs < 1) { + return "< 1ms"; + } + + return `${durationMs.toFixed(0)}ms`; +} + +function formatTime(timestamp: number): string { + return new Intl.DateTimeFormat("en-US", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).format(new Date(timestamp)); +} diff --git a/ui/studio/views/queries/query-insights-ai.test.ts b/ui/studio/views/queries/query-insights-ai.test.ts new file mode 100644 index 00000000..19afa6cd --- /dev/null +++ b/ui/studio/views/queries/query-insights-ai.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "vitest"; + +import type { StudioQueryInsightQuery } from "@/data/query-insights"; + +import { + buildQueryInsightAnalysisPrompt, + parseQueryInsightAnalysisResponse, +} from "./query-insights-ai"; + +const BASE_QUERY: StudioQueryInsightQuery = { + count: 3, + duration: 18, + id: "query-1", + lastSeen: 1_779_963_199_000, + query: "select * from users where email = $1", + reads: 9, + rowsReturned: 3, + tables: ["users"], +}; + +describe("query insights AI helpers", () => { + it("parses explicit analysis severity levels and trims useful text", () => { + expect( + parseQueryInsightAnalysisResponse( + JSON.stringify({ + improvedPrisma: " prisma.user.findMany({ select: { id: true } }) ", + improvedSql: " select id from users ", + level: "warning", + recommendations: [" Add a narrower projection. ", "", 3], + summary: " This query over-fetches. ", + }), + ), + ).toEqual({ + improvedPrisma: "prisma.user.findMany({ select: { id: true } })", + improvedSql: "select id from users", + level: "warning", + recommendations: ["Add a narrower projection."], + summary: "This query over-fetches.", + }); + }); + + it("keeps older AI responses compatible by inferring a useful severity", () => { + expect( + parseQueryInsightAnalysisResponse( + JSON.stringify({ + recommendations: ["Project only columns that the UI needs."], + summary: "The query can be tightened.", + }), + ).level, + ).toBe("info"); + + expect( + parseQueryInsightAnalysisResponse( + JSON.stringify({ + recommendations: [], + summary: "The query looks healthy.", + }), + ).level, + ).toBe("all-good"); + }); + + it("does not keep all-good severity when the response includes fix content", () => { + expect( + parseQueryInsightAnalysisResponse( + JSON.stringify({ + improvedSql: "select id from users", + level: "all-good", + recommendations: ["Project only columns that the UI needs."], + summary: "This can be improved.", + }), + ).level, + ).toBe("info"); + }); + + it("rejects malformed AI responses instead of rendering empty advice", () => { + expect(() => + parseQueryInsightAnalysisResponse( + JSON.stringify({ + level: "info", + recommendations: ["Use a smaller select list."], + }), + ), + ).toThrow('AI response must include a non-empty "summary" string.'); + }); + + it("builds a severity-aware prompt and only includes structured Prisma ORM context", () => { + const prompt = buildQueryInsightAnalysisPrompt({ + ...BASE_QUERY, + prismaQueryInfo: { + action: "findMany", + isRaw: false, + model: "User", + payload: { select: { id: true } }, + }, + }); + + expect(prompt).toContain('"level":"all-good"'); + expect(prompt).toContain("level must be one of all-good, info, or warning"); + expect(prompt).toContain( + 'Call the rowsReturned metric "rows returned" in user-facing text.', + ); + expect(prompt).toContain("- Rows returned: 3"); + expect(prompt).toContain("- Read work estimate: 9"); + expect(prompt).not.toContain("- Reads:"); + expect(prompt).toContain("Prisma ORM context:"); + expect(prompt).toContain('"model": "User"'); + expect(prompt).toContain("select * from users where email = $1"); + + const rawPrompt = buildQueryInsightAnalysisPrompt({ + ...BASE_QUERY, + prismaQueryInfo: { + action: "queryRaw", + isRaw: true, + }, + }); + + expect(rawPrompt).not.toContain("Prisma ORM context:"); + }); + + it("omits read work from the prompt when it only duplicates rows returned", () => { + const prompt = buildQueryInsightAnalysisPrompt({ + ...BASE_QUERY, + reads: 3, + rowsReturned: 3, + }); + + expect(prompt).toContain("- Rows returned: 3"); + expect(prompt).not.toContain("- Read work estimate:"); + expect(prompt).not.toContain("- Reads:"); + }); +}); diff --git a/ui/studio/views/queries/query-insights-ai.ts b/ui/studio/views/queries/query-insights-ai.ts new file mode 100644 index 00000000..c2f8828d --- /dev/null +++ b/ui/studio/views/queries/query-insights-ai.ts @@ -0,0 +1,162 @@ +import type { StudioQueryInsightQuery } from "@/data/query-insights"; + +import { normalizeAiJsonResponseText } from "../sql/ai-json-response"; + +export interface QueryInsightAnalysis { + level: QueryInsightAnalysisLevel; + improvedPrisma?: string; + improvedSql?: string; + recommendations: string[]; + summary: string; +} + +export type QueryInsightAnalysisLevel = "all-good" | "info" | "warning"; + +interface ParsedQueryInsightAnalysis { + improvedPrisma?: unknown; + improvedSql?: unknown; + level?: unknown; + recommendations?: unknown; + summary?: unknown; +} + +export function buildQueryInsightAnalysisPrompt( + query: StudioQueryInsightQuery, +): string { + const lines = [ + "You analyze SQL query performance for Prisma Studio.", + "Return JSON only. Do not include markdown fences or commentary.", + 'Return this exact top-level shape: {"level":"all-good","summary":"...","recommendations":["..."],"improvedSql":"...","improvedPrisma":"..."}', + "Rules:", + "- level must be one of all-good, info, or warning.", + "- Use all-good when the query looks healthy and no action is needed.", + "- Use info for minor or situational improvements.", + "- Use warning for likely performance issues, excessive work, or a risky query shape.", + "- Keep the summary to one or two short sentences.", + "- Recommendations must be concrete and actionable.", + "- Include improvedSql only when a concrete SQL rewrite is useful.", + "- Include improvedPrisma only when Prisma ORM context is present and a concrete Prisma Client rewrite is useful.", + "- Do not mention query parameter values; they are intentionally unavailable.", + '- Call the rowsReturned metric "rows returned" in user-facing text. Do not call rows returned "reads".', + '- Treat read work as an optional provider estimate. Mention it only as "read work" when it is materially useful.', + "", + "Query statistics:", + `- Executions: ${query.count}`, + `- Average latency: ${formatNumber(query.duration)} ms`, + `- Rows returned: ${formatNumber(query.rowsReturned)}`, + `- Tables: ${query.tables.length > 0 ? query.tables.join(", ") : "unknown"}`, + ]; + + if (hasDistinctReadWorkEstimate(query)) { + lines.push(`- Read work estimate: ${formatNumber(query.reads)}`); + } + + if (query.prismaQueryInfo && !query.prismaQueryInfo.isRaw) { + lines.push( + "", + "Prisma ORM context:", + JSON.stringify(query.prismaQueryInfo, null, 2), + ); + } + + lines.push("", "SQL:", query.query); + + return lines.join("\n"); +} + +export function parseQueryInsightAnalysisResponse( + responseText: string, +): QueryInsightAnalysis { + const normalizedResponseText = normalizeAiJsonResponseText(responseText); + const parsed = JSON.parse( + normalizedResponseText, + ) as ParsedQueryInsightAnalysis; + + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("AI response must be a JSON object."); + } + + const summary = + typeof parsed.summary === "string" && parsed.summary.trim().length > 0 + ? parsed.summary.trim() + : ""; + + if (summary.length === 0) { + throw new Error('AI response must include a non-empty "summary" string.'); + } + + const recommendations = Array.isArray(parsed.recommendations) + ? parsed.recommendations + .filter((recommendation): recommendation is string => { + return ( + typeof recommendation === "string" && + recommendation.trim().length > 0 + ); + }) + .map((recommendation) => recommendation.trim()) + : []; + + return { + level: normalizeAnalysisLevel(parsed.level, { + hasImprovedPrisma: + typeof parsed.improvedPrisma === "string" && + parsed.improvedPrisma.trim().length > 0, + hasImprovedSql: + typeof parsed.improvedSql === "string" && + parsed.improvedSql.trim().length > 0, + recommendationCount: recommendations.length, + }), + improvedPrisma: + typeof parsed.improvedPrisma === "string" && + parsed.improvedPrisma.trim().length > 0 + ? parsed.improvedPrisma.trim() + : undefined, + improvedSql: + typeof parsed.improvedSql === "string" && + parsed.improvedSql.trim().length > 0 + ? parsed.improvedSql.trim() + : undefined, + recommendations, + summary, + }; +} + +function normalizeAnalysisLevel( + level: unknown, + fallback: { + hasImprovedPrisma: boolean; + hasImprovedSql: boolean; + recommendationCount: number; + }, +): QueryInsightAnalysisLevel { + if (level === "warning" || level === "info") { + return level; + } + + const hasActionableAdvice = + fallback.recommendationCount > 0 || + fallback.hasImprovedSql || + fallback.hasImprovedPrisma; + + if (level === "all-good") { + return hasActionableAdvice ? "info" : "all-good"; + } + + if (hasActionableAdvice) { + return "info"; + } + + return "all-good"; +} + +function formatNumber(value: number): string { + return Number.isFinite(value) ? value.toFixed(0) : "unknown"; +} + +function hasDistinctReadWorkEstimate(query: StudioQueryInsightQuery): boolean { + return ( + Number.isFinite(query.reads) && + query.reads > 0 && + query.reads !== query.rowsReturned + ); +} diff --git a/ui/studio/views/sql/SqlResultVisualization.tsx b/ui/studio/views/sql/SqlResultVisualization.tsx index 79f0edfd..7248cbe0 100644 --- a/ui/studio/views/sql/SqlResultVisualization.tsx +++ b/ui/studio/views/sql/SqlResultVisualization.tsx @@ -1,17 +1,42 @@ -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { Bar } from "../../../components/charts/bar"; +import { BarChart } from "../../../components/charts/bar-chart"; +import { BarXAxis } from "../../../components/charts/bar-x-axis"; +import { BarYAxis } from "../../../components/charts/bar-y-axis"; +import { Grid } from "../../../components/charts/grid"; +import { Line } from "../../../components/charts/line"; +import { LineChart } from "../../../components/charts/line-chart"; +import { PieChart } from "../../../components/charts/pie-chart"; +import { PieSlice } from "../../../components/charts/pie-slice"; +import { ChartTooltip } from "../../../components/charts/tooltip"; +import { XAxis } from "../../../components/charts/x-axis"; import { cn } from "../../../lib/utils"; import { - createSqlResultVisualizationChart, resolveSqlResultVisualization, - type SqlResultVisualizationChartType, + type SqlResultVisualizationConfig, + type SqlResultVisualizationSeries, } from "./sql-result-visualization"; +const SQL_CHART_COLORS = [ + "var(--chart-1)", + "var(--chart-2)", + "var(--chart-3)", + "var(--chart-4)", + "var(--chart-5)", +] as const; + +interface ResolvedSqlResultVisualizationSeries { + color: string; + key: string; + label?: string; +} + export type SqlResultVisualizationState = | { status: "idle" } | { status: "loading" } | { - config: import("chart.js").ChartConfiguration; + config: SqlResultVisualizationConfig; status: "ready"; } | { message: string; status: "error" }; @@ -28,12 +53,10 @@ interface UseSqlResultVisualizationArgs { interface SqlResultVisualizationChartProps { className?: string; - config: import("chart.js").ChartConfiguration; + config: SqlResultVisualizationConfig; } -export function useSqlResultVisualization( - args: UseSqlResultVisualizationArgs, -) { +export function useSqlResultVisualization(args: UseSqlResultVisualizationArgs) { const { requestAiVisualization, aiQueryRequest, @@ -48,7 +71,8 @@ export function useSqlResultVisualization( status: "idle", }); const canGenerate = - typeof requestAiVisualization === "function" && typeof querySql === "string"; + typeof requestAiVisualization === "function" && + typeof querySql === "string"; const startVisualizationGeneration = useCallback(async () => { if (!requestAiVisualization || !querySql) { @@ -119,7 +143,13 @@ export function useSqlResultVisualization( setState((currentState) => { return currentState.status === "idle" ? currentState : { status: "idle" }; }); - }, [autoGenerate, canGenerate, querySql, resetKey, startVisualizationGeneration]); + }, [ + autoGenerate, + canGenerate, + querySql, + resetKey, + startVisualizationGeneration, + ]); return { canGenerate, @@ -132,29 +162,256 @@ export function SqlResultVisualizationChart( props: SqlResultVisualizationChartProps, ) { const { className, config } = props; - const canvasRef = useRef(null); - - useEffect(() => { - if (!canvasRef.current) { - return; - } - - const chart = createSqlResultVisualizationChart(canvasRef.current, config); - - return () => { - chart.destroy(); - }; - }, [config]); return (
- + {config.title ? ( +
+ {config.title} +
+ ) : null} +
+ +
+
+ ); +} + +function SqlResultVisualizationChartBody({ + config, +}: { + config: SqlResultVisualizationConfig; +}) { + if (config.type === "pie" || config.type === "doughnut") { + return ; + } + + if (config.type === "line") { + return ; + } + + return ; +} + +function SqlResultBarChart({ + config, +}: { + config: SqlResultVisualizationConfig; +}) { + const series = useResolvedSeries(config.series); + const isHorizontal = config.type === "horizontal-bar"; + + return ( + + + {series.map((item) => ( + + ))} + { + return series.map((item) => ({ + color: item.color, + label: item.label ?? item.key, + value: formatTooltipValue(point[item.key]), + })); + }} + showDatePill={false} + /> + {isHorizontal ? ( + + ) : ( + + )} + + ); +} + +function SqlResultLineChart({ + config, +}: { + config: SqlResultVisualizationConfig; +}) { + const series = useResolvedSeries(config.series); + + return ( + + + {series.map((item) => ( + + ))} + { + return series.map((item) => ({ + color: item.color, + label: item.label ?? item.key, + value: formatTooltipValue(point[item.key]), + })); + }} + /> + + + ); +} + +function SqlResultPieChart({ + config, +}: { + config: SqlResultVisualizationConfig; +}) { + const pieData = useMemo(() => { + const labelKey = config.labelKey ?? ""; + const valueKey = config.valueKey ?? ""; + + return config.data.reduce< + { color: string; label: string; value: number }[] + >((items, row, index) => { + const value = row[valueKey]; + + if (typeof value !== "number" || value <= 0) { + return items; + } + + items.push({ + color: getSqlChartColor(index), + label: String(row[labelKey] ?? "Unknown"), + value, + }); + return items; + }, []); + }, [config.data, config.labelKey, config.valueKey]); + + if (pieData.length === 0) { + return ( +
+ No positive numeric values to visualize. +
+ ); + } + + return ( +
+ + {pieData.map((item, index) => ( + + ))} + +
+ {pieData.slice(0, 8).map((item, index) => ( +
+
+ + + {item.label} + +
+ + {formatNumber(item.value)} + +
+ ))} +
); } + +function useResolvedSeries( + series: SqlResultVisualizationSeries[] | undefined, +): ResolvedSqlResultVisualizationSeries[] { + return useMemo(() => { + return (series ?? []).map((item, index) => { + return { + ...(item.label ? { label: item.label } : {}), + color: item.color ?? getSqlChartColor(index), + key: item.key, + }; + }); + }, [series]); +} + +function getSqlChartColor(index: number): string { + return SQL_CHART_COLORS[index % SQL_CHART_COLORS.length] ?? "var(--chart-1)"; +} + +function formatTooltipValue(value: unknown): string | number { + if (typeof value === "number") { + return formatNumber(value); + } + + if (typeof value === "string") { + return value; + } + + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + + return "n/a"; +} + +function formatNumber(value: number): string { + return new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, + }).format(value); +} diff --git a/ui/studio/views/sql/SqlView.test.tsx b/ui/studio/views/sql/SqlView.test.tsx index c18a3884..74197fd4 100644 --- a/ui/studio/views/sql/SqlView.test.tsx +++ b/ui/studio/views/sql/SqlView.test.tsx @@ -5,7 +5,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { Adapter, AdapterError } from "@/data"; +import type { Adapter, AdapterError, AdapterSqlLintResult } from "@/data"; import type { StudioLlmRequest } from "@/data/llm"; import { SqlView } from "./SqlView"; @@ -162,7 +162,11 @@ vi.mock("@uiw/react-codemirror", () => ({ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; -function createAdapterMock(args?: { raw?: Adapter["raw"] }): { +function createAdapterMock(args?: { + capabilities?: Adapter["capabilities"]; + raw?: Adapter["raw"]; + sqlLint?: Adapter["sqlLint"]; +}): { adapter: Adapter; rawSpy: ReturnType>; } { @@ -183,12 +187,14 @@ function createAdapterMock(args?: { raw?: Adapter["raw"] }): { return { adapter: { + ...(args?.capabilities ? { capabilities: args.capabilities } : {}), defaultSchema: "public", delete: vi.fn(), insert: vi.fn(), introspect: vi.fn(), query: vi.fn(), raw: rawSpy, + ...(args?.sqlLint ? { sqlLint: args.sqlLint } : {}), update: vi.fn(), } as unknown as Adapter, rawSpy, @@ -221,9 +227,7 @@ function createStudioMock(adapter: Adapter): { operationEvents: []; queryClient: { clear: ReturnType }; requestLlm: ReturnType< - typeof vi.fn< - (request: { prompt: string; task: string }) => Promise - > + typeof vi.fn<(request: { prompt: string; task: string }) => Promise> >; sqlEditorStateCollection: { delete: ReturnType; @@ -252,8 +256,12 @@ function createStudioMock(adapter: Adapter): { get: vi.fn((id: string) => sqlEditorRows.get(id)), has: vi.fn((id: string) => sqlEditorRows.has(id)), insert: vi.fn( - (item: { aiPromptHistory?: string[]; id: string; queryText?: string }) => { - sqlEditorRows.set(item.id, item); + (item: { + aiPromptHistory?: string[]; + id: string; + queryText?: string; + }) => { + sqlEditorRows.set(item.id, item); }, ), update: vi.fn( @@ -283,7 +291,9 @@ function createStudioMock(adapter: Adapter): { get llm() { return llm; }, - set llm(value: ((request: StudioLlmRequest) => Promise) | undefined) { + set llm( + value: ((request: StudioLlmRequest) => Promise) | undefined, + ) { llm = value; }, getOrCreateRowsCollection: vi.fn(), @@ -336,7 +346,12 @@ function setInputValue(element: HTMLInputElement, value: string) { function dispatchInputKey( element: HTMLInputElement, key: string, - args?: { altKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; shiftKey?: boolean }, + args?: { + altKey?: boolean; + ctrlKey?: boolean; + metaKey?: boolean; + shiftKey?: boolean; + }, ) { element.dispatchEvent( new KeyboardEvent("keydown", { @@ -422,7 +437,9 @@ describe("SqlView", () => { } expect( - harness.container.querySelector('input[aria-label="Generate SQL with AI"]'), + harness.container.querySelector( + 'input[aria-label="Generate SQL with AI"]', + ), ).toBeNull(); expect( [...harness.container.querySelectorAll("button")].find((button) => @@ -441,10 +458,14 @@ describe("SqlView", () => { }); expect( - harness.container.querySelector('[data-testid="sql-result-visualization-action"]'), + harness.container.querySelector( + '[data-testid="sql-result-visualization-action"]', + ), ).toBeNull(); expect( - harness.container.querySelector('[data-testid="sql-result-visualization-row"]'), + harness.container.querySelector( + '[data-testid="sql-result-visualization-row"]', + ), ).toBeNull(); harness.cleanup(); @@ -468,9 +489,9 @@ describe("SqlView", () => { const promptInput = harness.container.querySelector( 'input[aria-label="Generate SQL with AI"]', ); - const generateButton = [...harness.container.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Generate SQL"), - ); + const generateButton = [ + ...harness.container.querySelectorAll("button"), + ].find((button) => button.textContent?.includes("Generate SQL")); const editor = harness.container.querySelector( 'textarea[aria-label="SQL editor"]', ); @@ -524,7 +545,9 @@ describe("SqlView", () => { }); await waitFor(() => { - return harness.container.textContent?.includes("1 row(s) returned") ?? false; + return ( + harness.container.textContent?.includes("1 row(s) returned") ?? false + ); }); expect(rawSpy).toHaveBeenCalledWith( @@ -550,6 +573,196 @@ describe("SqlView", () => { harness.cleanup(); }); + it("validates and corrects generated SQL before placing it in the editor", async () => { + const invalidSql = + "select tm.skills as skill, count(*) from public.team_members tm group by skill;"; + const correctedSql = + "select skill, count(*) from public.team_members tm cross join lateral unnest(tm.skills) as skill group by skill;"; + const invalidLintResult: AdapterSqlLintResult = { + diagnostics: [ + { + from: 7, + message: + 'column "tm.skills" must appear in the GROUP BY clause or be used in an aggregate function', + severity: "error", + source: "postgres", + to: 16, + }, + ], + }; + const validLintResult: AdapterSqlLintResult = { diagnostics: [] }; + const sqlLintMock = vi + .fn>() + .mockResolvedValueOnce([null, invalidLintResult]) + .mockResolvedValueOnce([null, validLintResult]); + const { adapter, rawSpy } = createAdapterMock({ + capabilities: { + sqlDialect: "postgresql", + sqlEditorLint: true, + }, + sqlLint: sqlLintMock, + }); + const studio = createStudioMock(adapter); + const llmMock = vi + .fn<(request: StudioLlmRequest) => Promise>() + .mockResolvedValueOnce( + JSON.stringify({ + rationale: "Uses the skills column directly.", + sql: invalidSql, + shouldGenerateVisualization: true, + }), + ) + .mockResolvedValueOnce( + JSON.stringify({ + rationale: "Unnests skills before grouping.", + sql: correctedSql, + shouldGenerateVisualization: true, + }), + ); + studio.llm = llmMock; + useStudioMock.mockReturnValue(studio); + + const harness = renderSqlView(); + const promptInput = harness.container.querySelector( + 'input[aria-label="Generate SQL with AI"]', + ); + const generateButton = [ + ...harness.container.querySelectorAll("button"), + ].find((button) => button.textContent?.includes("Generate SQL")); + const editor = harness.container.querySelector( + 'textarea[aria-label="SQL editor"]', + ); + + if (!promptInput || !generateButton || !editor) { + throw new Error("Expected SQL generation controls and editor"); + } + + act(() => { + setInputValue(promptInput, "count team members by skill"); + }); + + act(() => { + generateButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => editor.value === correctedSql); + + expect(rawSpy).not.toHaveBeenCalled(); + expect(sqlLintMock).toHaveBeenCalledTimes(2); + expect(sqlLintMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ sql: invalidSql }), + expect.any(Object), + ); + expect(sqlLintMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ sql: correctedSql }), + expect.any(Object), + ); + expect(llmMock).toHaveBeenCalledTimes(2); + expect(llmMock.mock.calls[1]?.[0].prompt).toContain( + `Previous SQL statement: ${invalidSql}`, + ); + expect(llmMock.mock.calls[1]?.[0].prompt).toContain( + 'Database error from that SQL: column "tm.skills" must appear in the GROUP BY clause or be used in an aggregate function', + ); + expect(harness.container.textContent).not.toContain( + "Uses the skills column directly.", + ); + expect(harness.container.textContent).toContain( + "Unnests skills before grouping.", + ); + + harness.cleanup(); + }); + + it("leaves the editor unchanged when generated SQL validation cannot be corrected", async () => { + const invalidSql = "select * from missing_table;"; + const stillInvalidSql = "select id from missing_table;"; + const invalidLintResult: AdapterSqlLintResult = { + diagnostics: [ + { + from: 14, + message: 'relation "missing_table" does not exist', + severity: "error", + source: "postgres", + to: 27, + }, + ], + }; + const sqlLintMock = vi + .fn>() + .mockResolvedValueOnce([null, invalidLintResult]) + .mockResolvedValueOnce([null, invalidLintResult]); + const { adapter, rawSpy } = createAdapterMock({ + capabilities: { + sqlDialect: "postgresql", + sqlEditorLint: true, + }, + sqlLint: sqlLintMock, + }); + const studio = createStudioMock(adapter); + const llmMock = vi + .fn<(request: StudioLlmRequest) => Promise>() + .mockResolvedValueOnce( + JSON.stringify({ + rationale: "Uses a missing table.", + sql: invalidSql, + shouldGenerateVisualization: false, + }), + ) + .mockResolvedValueOnce( + JSON.stringify({ + rationale: "Still uses a missing table.", + sql: stillInvalidSql, + shouldGenerateVisualization: false, + }), + ); + studio.llm = llmMock; + useStudioMock.mockReturnValue(studio); + + const harness = renderSqlView(); + const promptInput = harness.container.querySelector( + 'input[aria-label="Generate SQL with AI"]', + ); + const generateButton = [ + ...harness.container.querySelectorAll("button"), + ].find((button) => button.textContent?.includes("Generate SQL")); + const editor = harness.container.querySelector( + 'textarea[aria-label="SQL editor"]', + ); + + if (!promptInput || !generateButton || !editor) { + throw new Error("Expected SQL generation controls and editor"); + } + + act(() => { + setInputValue(promptInput, "show me the missing table"); + }); + + act(() => { + generateButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => { + return ( + harness.container.textContent?.includes( + "AI-generated SQL did not pass validation", + ) ?? false + ); + }); + + expect(editor.value).toBe("select * from "); + expect(rawSpy).not.toHaveBeenCalled(); + expect(sqlLintMock).toHaveBeenCalledTimes(2); + expect(llmMock).toHaveBeenCalledTimes(2); + expect(harness.container.textContent).not.toContain( + "Still uses a missing table.", + ); + + harness.cleanup(); + }); + it("auto-generates a chart after the user runs AI-generated SQL marked as graph-worthy", async () => { const { adapter } = createAdapterMock(); const studio = createStudioMock(adapter); @@ -565,19 +778,10 @@ describe("SqlView", () => { .mockResolvedValueOnce( JSON.stringify({ config: { - data: { - datasets: [ - { - data: [1], - label: "Rows", - }, - ], - labels: ["organizations"], - }, - options: { - responsive: false, - }, - type: "bar", + data: [{ label: "Organizations with a very long label", rows: 1 }], + series: [{ key: "rows", label: "Rows" }], + type: "horizontal-bar", + xKey: "label", }, }), ); @@ -588,9 +792,9 @@ describe("SqlView", () => { const promptInput = harness.container.querySelector( 'input[aria-label="Generate SQL with AI"]', ); - const generateButton = [...harness.container.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Generate SQL"), - ); + const generateButton = [ + ...harness.container.querySelectorAll("button"), + ].find((button) => button.textContent?.includes("Generate SQL")); if (!promptInput || !generateButton) { throw new Error("Expected SQL generation controls"); @@ -636,7 +840,8 @@ describe("SqlView", () => { await waitFor(() => { return ( - (harness.container.textContent?.includes("1 row(s) returned") ?? false) && + (harness.container.textContent?.includes("1 row(s) returned") ?? + false) && harness.container.querySelector( '[data-testid="sql-result-visualization-chart"]', ) != null @@ -647,7 +852,7 @@ describe("SqlView", () => { expect(llmMock).toHaveBeenNthCalledWith( 2, expect.objectContaining({ - prompt: expect.stringContaining("Chart.js"), + prompt: expect.stringContaining("Bklit chart components"), task: "sql-visualization", }), ); @@ -659,14 +864,17 @@ describe("SqlView", () => { JSON.stringify([{ one: 1 }]), ); expect( - harness.container.querySelector('[data-testid="sql-result-visualization-action"]'), + harness.container.querySelector( + '[data-testid="sql-result-visualization-action"]', + ), ).toBeNull(); harness.cleanup(); }); - it("surfaces query errors only after the user manually runs AI-generated SQL", async () => { + it("feeds AI-generated SQL execution errors back into the model without auto-running the correction", async () => { const badSql = "select typeof(json_col) from public.organizations limit 5;"; + const correctedSql = "select id from public.organizations limit 5;"; const raw: Adapter["raw"] = async (details) => { const error = new Error( "function typeof(json) does not exist", @@ -676,11 +884,20 @@ describe("SqlView", () => { }; const { adapter, rawSpy } = createAdapterMock({ raw }); const studio = createStudioMock(adapter); - const llmMock = vi.fn<(request: StudioLlmRequest) => Promise>() - .mockResolvedValue( + const llmMock = vi + .fn<(request: StudioLlmRequest) => Promise>() + .mockResolvedValueOnce( JSON.stringify({ rationale: "Tried a typeof helper.", sql: badSql, + shouldGenerateVisualization: false, + }), + ) + .mockResolvedValueOnce( + JSON.stringify({ + rationale: "Uses plain selectable columns after the database error.", + sql: correctedSql, + shouldGenerateVisualization: true, }), ); studio.llm = llmMock; @@ -690,9 +907,9 @@ describe("SqlView", () => { const promptInput = harness.container.querySelector( 'input[aria-label="Generate SQL with AI"]', ); - const generateButton = [...harness.container.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Generate SQL"), - ); + const generateButton = [ + ...harness.container.querySelectorAll("button"), + ].find((button) => button.textContent?.includes("Generate SQL")); const editor = harness.container.querySelector( 'textarea[aria-label="SQL editor"]', ); @@ -727,25 +944,168 @@ describe("SqlView", () => { runButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); + await waitFor(() => editor.value === correctedSql); + + expect(llmMock).toHaveBeenCalledTimes(2); + expect(llmMock.mock.calls[1]?.[0]).toMatchObject({ + task: "sql-generation", + }); + expect(llmMock.mock.calls[1]?.[0].prompt).toContain( + "Previous SQL statement: select typeof(json_col) from public.organizations limit 5", + ); + expect(llmMock.mock.calls[1]?.[0].prompt).toContain( + "Database error from that SQL: function typeof(json) does not exist", + ); + expect(rawSpy).toHaveBeenCalledTimes(1); + expect(rawSpy).toHaveBeenCalledWith( + { sql: "select typeof(json_col) from public.organizations limit 5" }, + expect.any(Object), + ); + expect(harness.container.textContent).toContain( + "Uses plain selectable columns after the database error.", + ); + expect(harness.container.textContent).not.toContain("Query error:"); + + harness.cleanup(); + }); + + it("keeps normal manual SQL execution errors inline without asking AI for a correction", async () => { + const raw: Adapter["raw"] = async (details) => { + const error = new Error( + "relation missing_table does not exist", + ) as AdapterError; + error.query = { parameters: [], sql: details.sql }; + return [error]; + }; + const { adapter, rawSpy } = createAdapterMock({ raw }); + const studio = createStudioMock(adapter); + const llmMock = vi.fn<(request: StudioLlmRequest) => Promise>(); + studio.llm = llmMock; + useStudioMock.mockReturnValue(studio); + + const harness = renderSqlView(); + const editor = harness.container.querySelector( + 'textarea[aria-label="SQL editor"]', + ); + const runButton = [...harness.container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Run SQL"), + ); + + if (!editor || !runButton) { + throw new Error("Expected SQL editor and Run SQL button"); + } + + act(() => { + editor.value = "select * from missing_table"; + mockCodeMirrorOnChange?.("select * from missing_table"); + }); + + act(() => { + runButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await waitFor(() => { return ( harness.container.textContent?.includes( - "function typeof(json) does not exist", + "relation missing_table does not exist", ) ?? false ); }); - expect(llmMock).toHaveBeenCalledTimes(1); + expect(llmMock).not.toHaveBeenCalled(); expect(rawSpy).toHaveBeenCalledTimes(1); - expect(rawSpy).toHaveBeenCalledWith( - { sql: "select typeof(json_col) from public.organizations limit 5" }, - expect.any(Object), + expect(harness.container.textContent).toContain("Query error:"); + + harness.cleanup(); + }); + + it("aborts in-flight SQL execution when the view unmounts", async () => { + let executionSignal: AbortSignal | undefined; + const pendingExecution = + createDeferred>>(); + const raw: Adapter["raw"] = (_details, options) => { + executionSignal = options?.abortSignal; + return pendingExecution.promise; + }; + const { adapter } = createAdapterMock({ raw }); + useStudioMock.mockReturnValue(createStudioMock(adapter)); + + const harness = renderSqlView(); + const runButton = [...harness.container.querySelectorAll("button")].find( + (button) => button.textContent?.includes("Run SQL"), ); - expect(harness.container.textContent).toContain( - "Tried a typeof helper.", + + if (!runButton) { + throw new Error("Expected Run SQL button"); + } + + act(() => { + runButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => executionSignal !== undefined); + + expect(executionSignal?.aborted).toBe(false); + + harness.cleanup(); + + expect(executionSignal?.aborted).toBe(true); + }); + + it("aborts in-flight AI SQL validation when the view unmounts", async () => { + let validationSignal: AbortSignal | undefined; + const pendingValidation = + createDeferred>>>(); + const sqlLint: NonNullable = (_details, options) => { + validationSignal = options?.abortSignal; + return pendingValidation.promise; + }; + const { adapter } = createAdapterMock({ + capabilities: { + sqlDialect: "postgresql", + sqlEditorLint: true, + }, + sqlLint, + }); + const studio = createStudioMock(adapter); + studio.llm = vi.fn(() => + Promise.resolve( + JSON.stringify({ + rationale: "Reads organizations.", + sql: "select * from public.organizations", + shouldGenerateVisualization: false, + }), + ), ); + useStudioMock.mockReturnValue(studio); + + const harness = renderSqlView(); + const promptInput = harness.container.querySelector( + 'input[aria-label="Generate SQL with AI"]', + ); + const generateButton = [ + ...harness.container.querySelectorAll("button"), + ].find((button) => button.textContent?.includes("Generate SQL")); + + if (!promptInput || !generateButton) { + throw new Error("Expected SQL generation controls"); + } + + act(() => { + setInputValue(promptInput, "show me organizations"); + }); + + act(() => { + generateButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => validationSignal !== undefined); + + expect(validationSignal?.aborted).toBe(false); harness.cleanup(); + + expect(validationSignal?.aborted).toBe(true); }); it("persists generated AI SQL prompts in the local SQL editor collection", async () => { @@ -764,9 +1124,9 @@ describe("SqlView", () => { const promptInput = harness.container.querySelector( 'input[aria-label="Generate SQL with AI"]', ); - const generateButton = [...harness.container.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Generate SQL"), - ); + const generateButton = [ + ...harness.container.querySelectorAll("button"), + ].find((button) => button.textContent?.includes("Generate SQL")); if (!promptInput || !generateButton) { throw new Error("Expected SQL generation controls"); @@ -782,11 +1142,13 @@ describe("SqlView", () => { await waitFor(() => { const promptHistoryRow = ( - studio.sqlEditorStateCollection.get as (id: string) => { - aiPromptHistory?: string[]; - id: string; - queryText?: string; - } | undefined + studio.sqlEditorStateCollection.get as (id: string) => + | { + aiPromptHistory?: string[]; + id: string; + queryText?: string; + } + | undefined )("sql-editor:ai-prompt-history"); return ( @@ -810,11 +1172,13 @@ describe("SqlView", () => { expect( ( - studio.sqlEditorStateCollection.get as (id: string) => { - aiPromptHistory?: string[]; - id: string; - queryText?: string; - } | undefined + studio.sqlEditorStateCollection.get as (id: string) => + | { + aiPromptHistory?: string[]; + id: string; + queryText?: string; + } + | undefined )("sql-editor:ai-prompt-history"), ).toEqual({ aiPromptHistory: ["show me team members", "show me organizations"], @@ -916,9 +1280,9 @@ describe("SqlView", () => { const promptInput = harness.container.querySelector( 'input[aria-label="Generate SQL with AI"]', ); - const generateButton = [...harness.container.querySelectorAll("button")].find( - (button) => button.textContent?.includes("Generate SQL"), - ); + const generateButton = [ + ...harness.container.querySelectorAll("button"), + ].find((button) => button.textContent?.includes("Generate SQL")); if (!promptInput || !generateButton) { throw new Error("Expected SQL generation controls"); @@ -934,8 +1298,9 @@ describe("SqlView", () => { await waitFor(() => { return ( - harness.container.textContent?.includes("AI SQL generation exploded.") ?? - false + harness.container.textContent?.includes( + "AI SQL generation exploded.", + ) ?? false ); }); @@ -949,19 +1314,10 @@ describe("SqlView", () => { async () => JSON.stringify({ config: { - data: { - datasets: [ - { - data: [1], - label: "Rows", - }, - ], - labels: ["organizations"], - }, - options: { - responsive: false, - }, + data: [{ label: "organizations", rows: 1 }], + series: [{ key: "rows", label: "Rows" }], type: "bar", + xKey: "label", }, }), ); @@ -999,7 +1355,9 @@ describe("SqlView", () => { } expect( - harness.container.querySelector('[data-testid="sql-result-visualization-row"]'), + harness.container.querySelector( + '[data-testid="sql-result-visualization-row"]', + ), ).toBeNull(); expect(summary.textContent).toContain("1 row(s) returned in"); expect(harness.container.textContent?.includes("AI visualization")).toBe( @@ -1007,16 +1365,14 @@ describe("SqlView", () => { ); expect( harness.container.textContent?.includes( - "Generate a Chart.js view from the current SQL result set.", + "Generate a Bklit chart from the current SQL result set.", ), ).toBe(false); expect(visualizeAction.textContent).toContain("Visualize data with AI"); expect(visualizeAction.className.includes("border")).toBe(false); act(() => { - visualizeAction.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); + visualizeAction.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await waitFor(() => { @@ -1036,12 +1392,13 @@ describe("SqlView", () => { ); expect(llmMock).toHaveBeenCalledTimes(1); - expect(firstVisualizationPrompt).toContain("Chart.js"); + expect(firstVisualizationPrompt).toContain("Bklit chart components"); + expect(firstVisualizationPrompt).toContain("horizontal-bar"); expect(firstVisualizationPrompt).toContain("SQL: select 1 as one"); expect(firstVisualizationPrompt).toContain(JSON.stringify([{ one: 1 }])); expect(visualizationBand?.className).toContain("sticky"); expect(visualizationBand?.className).toContain("w-[100cqw]"); - expect(visualizationBand?.className).toContain("bg-white"); + expect(visualizationBand?.className).toContain("bg-background"); expect(visualizationBand?.className).toContain("border-b"); expect(chart?.className).toContain("mx-auto"); expect(chart?.className).toContain( @@ -1051,7 +1408,9 @@ describe("SqlView", () => { expect(chart?.className).toContain("max-w-[1200px]"); expect(firstVisualizationPrompt).not.toContain("AI query request:"); expect( - harness.container.querySelector('[data-testid="sql-result-visualization-action"]'), + harness.container.querySelector( + '[data-testid="sql-result-visualization-action"]', + ), ).toBeNull(); harness.cleanup(); @@ -1059,7 +1418,14 @@ describe("SqlView", () => { it("resets the generated chart when another query starts running", async () => { const secondQueryDeferred = createDeferred< - [null, { query: { parameters: never[]; sql: string }; rowCount: number; rows: { one: number }[] }] + [ + null, + { + query: { parameters: never[]; sql: string }; + rowCount: number; + rows: { one: number }[]; + }, + ] >(); let rawCallCount = 0; const raw: Adapter["raw"] = async (details) => { @@ -1083,19 +1449,10 @@ describe("SqlView", () => { studio.llm = vi.fn(async () => JSON.stringify({ config: { - data: { - datasets: [ - { - data: [1], - label: "Rows", - }, - ], - labels: ["organizations"], - }, - options: { - responsive: false, - }, - type: "pie", + data: [{ label: "organizations", rows: 1 }], + series: [{ key: "rows", label: "Rows" }], + type: "bar", + xKey: "label", }, }), ); @@ -1129,9 +1486,7 @@ describe("SqlView", () => { } act(() => { - visualizeAction.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); + visualizeAction.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await waitFor(() => { diff --git a/ui/studio/views/sql/SqlView.tsx b/ui/studio/views/sql/SqlView.tsx index 904ec5ac..56c12540 100644 --- a/ui/studio/views/sql/SqlView.tsx +++ b/ui/studio/views/sql/SqlView.tsx @@ -16,6 +16,7 @@ import type { Adapter, AdapterError, AdapterRawResult, + AdapterSqlLintDiagnostic, Column, DataTypeGroup, } from "../../../../data/adapter"; @@ -37,19 +38,17 @@ import { DataGridDraggableHeaderCell } from "../../grid/DataGridDraggableHeaderC import { DataGridHeader } from "../../grid/DataGridHeader"; import { StudioHeader } from "../../StudioHeader"; import type { ViewProps } from "../View"; +import { resolveAiSqlGeneration } from "./sql-ai-generation"; import { getCodeMirrorDialect, toCodeMirrorSqlNamespace, } from "./sql-editor-config"; import { createSqlEditorKeybindings } from "./sql-editor-keybindings"; -import { - resolveAiSqlGeneration, -} from "./sql-ai-generation"; +import { createSqlLintSource } from "./sql-lint-source"; import { SqlResultVisualizationChart, useSqlResultVisualization, } from "./SqlResultVisualization"; -import { createSqlLintSource } from "./sql-lint-source"; interface SqlResultState { aiQueryRequest: string | null; @@ -89,6 +88,7 @@ const SQL_VIEW_GRID_SCOPE = "sql:view:grid"; const SQL_VIEW_TABLE_NAME = "__sql_result__"; const SQL_VIEW_SCHEMA = "__sql_result__"; const EMPTY_SQL_RESULT_ROWS: Record[] = []; +const MAX_AI_SQL_VALIDATION_CORRECTIONS = 1; const DEFAULT_PAGINATION_STATE: PaginationState = { pageIndex: 0, pageSize: 25, @@ -226,7 +226,7 @@ const SqlResultGrid = memo(function SqlResultGrid(props: SqlResultGridProps) { colSpan={Math.max(table.getAllLeafColumns().length, 1)} >
(null); + const sqlValidationAbortControllerRef = useRef(null); const editorViewRef = useRef(null); const aiPromptInputRef = useRef(null); + const isMountedRef = useRef(true); const runCurrentSqlRef = useRef<() => void>(() => undefined); const persistedSqlDraftRef = useRef(initialPersistedSqlDraft); const [editorValue, setEditorValue] = useState(() => { @@ -292,10 +294,14 @@ export function SqlView(_props: ViewProps) { const [aiGenerationErrorMessage, setAiGenerationErrorMessage] = useState< string | null >(null); + const [aiCorrectionErrorMessage, setAiCorrectionErrorMessage] = useState< + string | null + >(null); const [aiGenerationRationale, setAiGenerationRationale] = useState< string | null >(null); const [isGeneratingSql, setIsGeneratingSql] = useState(false); + const [isCorrectingSql, setIsCorrectingSql] = useState(false); const [pendingAiSqlExecution, setPendingAiSqlExecution] = useState(null); const [errorMessage, setErrorMessage] = useState(null); @@ -356,7 +362,9 @@ export function SqlView(_props: ViewProps) { } setAiPromptHistory(nextHistory); - const existingState = sqlEditorStateCollection.get(SQL_AI_PROMPT_HISTORY_ID); + const existingState = sqlEditorStateCollection.get( + SQL_AI_PROMPT_HISTORY_ID, + ); if (!existingState) { sqlEditorStateCollection.insert({ @@ -375,7 +383,7 @@ export function SqlView(_props: ViewProps) { const aiPromptHistoryPreview = aiPrompt.length === 0 && aiPromptHistoryPreviewIndex != null - ? aiPromptHistory[aiPromptHistoryPreviewIndex] ?? null + ? (aiPromptHistory[aiPromptHistoryPreviewIndex] ?? null) : null; const materializeAiPromptHistoryPreview = useCallback(() => { @@ -429,6 +437,16 @@ export function SqlView(_props: ViewProps) { aiPromptHistoryRef.current = aiPromptHistory; }, [aiPromptHistory]); + useEffect(() => { + return () => { + isMountedRef.current = false; + abortControllerRef.current?.abort(); + abortControllerRef.current = null; + sqlValidationAbortControllerRef.current?.abort(); + sqlValidationAbortControllerRef.current = null; + }; + }, []); + useEffect(() => { if (!hasUserEditedEditorValueRef.current) { return; @@ -527,7 +545,9 @@ export function SqlView(_props: ViewProps) { ]; }, [sqlLanguageExtension, sqlLintExtensions]); const databaseEngine = useMemo(() => { - return getDatabaseEngineName(adapter.capabilities?.sqlDialect ?? "postgresql"); + return getDatabaseEngineName( + adapter.capabilities?.sqlDialect ?? "postgresql", + ); }, [adapter.capabilities?.sqlDialect]); const requestAiSqlGeneration = useCallback( async (prompt: string) => { @@ -557,6 +577,127 @@ export function SqlView(_props: ViewProps) { rows: result?.rows ?? EMPTY_SQL_RESULT_ROWS, }); + function applyAiSqlGenerationResult(args: { + aiQueryRequest: string; + rationale: string | null; + shouldGenerateVisualization: boolean; + sql: string; + }) { + flushSync(() => { + hasUserEditedEditorValueRef.current = true; + latestEditorValueRef.current = args.sql; + setEditorValue(args.sql); + setAiGenerationRationale(args.rationale); + setPendingAiSqlExecution({ + aiQueryRequest: args.aiQueryRequest, + shouldAutoGenerateVisualization: args.shouldGenerateVisualization, + sql: args.sql, + }); + }); + focusSqlEditorAtEnd(args.sql); + } + + async function resolveValidatedAiSqlGeneration(args: { + previousSql?: string; + queryErrorMessage?: string; + request: string; + }) { + const generationIntrospection = introspection; + + if (!generationIntrospection) { + throw new Error( + "Schema metadata is still loading. Try again in a moment.", + ); + } + + let generation = await resolveAiSqlGeneration({ + activeSchema: schemaParam ?? adapter.defaultSchema ?? "public", + requestAiSqlGeneration, + dialect: adapter.capabilities?.sqlDialect ?? "postgresql", + introspection: generationIntrospection, + previousSql: args.previousSql, + queryErrorMessage: args.queryErrorMessage, + request: args.request, + }); + + for ( + let correctionCount = 0; + correctionCount <= MAX_AI_SQL_VALIDATION_CORRECTIONS; + correctionCount += 1 + ) { + const validationMessage = await validateGeneratedSqlBeforeDisplay( + generation.sql, + ); + + if (!validationMessage) { + return generation; + } + + if (correctionCount === MAX_AI_SQL_VALIDATION_CORRECTIONS) { + throw new Error( + `AI-generated SQL did not pass validation: ${validationMessage}`, + ); + } + + generation = await resolveAiSqlGeneration({ + activeSchema: schemaParam ?? adapter.defaultSchema ?? "public", + requestAiSqlGeneration, + dialect: adapter.capabilities?.sqlDialect ?? "postgresql", + introspection: generationIntrospection, + previousSql: generation.sql, + queryErrorMessage: validationMessage, + request: args.request, + }); + } + + return generation; + } + + async function validateGeneratedSqlBeforeDisplay( + sql: string, + ): Promise { + if ( + !adapter.capabilities?.sqlEditorLint || + !adapterSupportsSqlLint(adapter) + ) { + return null; + } + + const abortController = new AbortController(); + sqlValidationAbortControllerRef.current = abortController; + const [error, result] = await adapter.sqlLint( + { + schemaVersion: sqlEditorSchema.version, + sql, + }, + { abortSignal: abortController.signal }, + ); + + if (sqlValidationAbortControllerRef.current === abortController) { + sqlValidationAbortControllerRef.current = null; + } + + if (!isMountedRef.current) { + return null; + } + + if (error) { + throw new Error(`AI SQL validation failed: ${error.message}`); + } + + const blockingDiagnostics = result.diagnostics.filter((diagnostic) => { + return diagnostic.severity === "error"; + }); + + if (blockingDiagnostics.length === 0) { + return null; + } + + return blockingDiagnostics + .map(formatSqlLintDiagnosticForAiCorrection) + .join("\n"); + } + async function runSqlRequest(args: { aiQueryRequest?: string | null; sql: string; @@ -573,6 +714,7 @@ export function SqlView(_props: ViewProps) { abortControllerRef.current = abortController; setIsRunning(true); setErrorMessage(null); + setAiCorrectionErrorMessage(null); setVisualizationResetKey((currentValue) => currentValue + 1); const [error, rawResult] = await adapter.raw( @@ -583,7 +725,15 @@ export function SqlView(_props: ViewProps) { const durationMs = consumeBffRequestDurationMsForSignal(abortController.signal) ?? Math.round(performance.now() - startedAt); - abortControllerRef.current = null; + + if (abortControllerRef.current === abortController) { + abortControllerRef.current = null; + } + + if (!isMountedRef.current) { + return null; + } + setIsRunning(false); return { @@ -641,6 +791,7 @@ export function SqlView(_props: ViewProps) { setRowSelectionState({}); setPaginationState(DEFAULT_PAGINATION_STATE); setErrorMessage(null); + setAiCorrectionErrorMessage(null); if (reportEvents) { onEvent({ @@ -672,6 +823,14 @@ export function SqlView(_props: ViewProps) { } applySqlExecutionOutcome(outcome); + + if (outcome.error && outcome.error.name !== "AbortError") { + await correctAiGeneratedSqlAfterQueryError({ + aiQueryRequest: aiExecutionContext.aiQueryRequest, + failedSql: sql.trim(), + queryErrorMessage: outcome.error.message, + }); + } } function getSqlForExecutionFromCursor(): string { @@ -705,7 +864,7 @@ export function SqlView(_props: ViewProps) { } async function generateSqlFromPrompt() { - if (!hasAiSql || isGeneratingSql) { + if (!hasAiSql || isGeneratingSql || isCorrectingSql) { return; } @@ -726,41 +885,92 @@ export function SqlView(_props: ViewProps) { setAiPromptHistoryPreviewIndex(null); setIsGeneratingSql(true); setAiGenerationErrorMessage(null); + setAiCorrectionErrorMessage(null); setAiGenerationRationale(null); setErrorMessage(null); setPendingAiSqlExecution(null); setResult(null); try { - const generation = await resolveAiSqlGeneration({ - activeSchema: schemaParam ?? adapter.defaultSchema ?? "public", - requestAiSqlGeneration, - dialect: adapter.capabilities?.sqlDialect ?? "postgresql", - introspection, + const generation = await resolveValidatedAiSqlGeneration({ request: trimmedPrompt, }); - flushSync(() => { - hasUserEditedEditorValueRef.current = true; - latestEditorValueRef.current = generation.sql; - setEditorValue(generation.sql); - setAiGenerationRationale(generation.rationale); - setPendingAiSqlExecution({ - aiQueryRequest: trimmedPrompt, - shouldAutoGenerateVisualization: - generation.shouldGenerateVisualization, - sql: generation.sql, - }); + if (!isMountedRef.current) { + return; + } + + applyAiSqlGenerationResult({ + aiQueryRequest: trimmedPrompt, + rationale: generation.rationale, + shouldGenerateVisualization: generation.shouldGenerateVisualization, + sql: generation.sql, }); - focusSqlEditorAtEnd(generation.sql); } catch (error) { + if (!isMountedRef.current) { + return; + } + setAiGenerationErrorMessage( - error instanceof Error - ? error.message - : "AI SQL generation failed.", + error instanceof Error ? error.message : "AI SQL generation failed.", ); } finally { - setIsGeneratingSql(false); + if (isMountedRef.current) { + setIsGeneratingSql(false); + } + } + } + + async function correctAiGeneratedSqlAfterQueryError(args: { + aiQueryRequest: string | null; + failedSql: string; + queryErrorMessage: string; + }) { + if ( + !hasAiSql || + !args.aiQueryRequest || + args.failedSql.length === 0 || + isCorrectingSql || + !introspection + ) { + return; + } + + setIsCorrectingSql(true); + setAiGenerationErrorMessage(null); + setAiCorrectionErrorMessage(null); + + try { + const generation = await resolveValidatedAiSqlGeneration({ + previousSql: args.failedSql, + queryErrorMessage: args.queryErrorMessage, + request: args.aiQueryRequest, + }); + + if (!isMountedRef.current) { + return; + } + + setResult(null); + setErrorMessage(null); + applyAiSqlGenerationResult({ + aiQueryRequest: args.aiQueryRequest, + rationale: generation.rationale, + shouldGenerateVisualization: generation.shouldGenerateVisualization, + sql: generation.sql, + }); + } catch (error) { + if (!isMountedRef.current) { + return; + } + + setAiCorrectionErrorMessage( + error instanceof Error ? error.message : "AI SQL correction failed.", + ); + } finally { + if (isMountedRef.current) { + setIsCorrectingSql(false); + } } } @@ -795,11 +1005,7 @@ export function SqlView(_props: ViewProps) { size="sm" variant={isRunning ? "outline" : "default"} > - {isRunning ? ( - - ) : ( - - )} + {isRunning ? : } {isRunning ? "Cancel" : "Run SQL"} ); @@ -812,7 +1018,7 @@ export function SqlView(_props: ViewProps) { { void materializeAiPromptHistoryPreview(); }} @@ -857,7 +1063,11 @@ export function SqlView(_props: ViewProps) { value={aiPrompt} />
) : null} + {isCorrectingSql ? ( +
+ + Correcting SQL with AI... +
+ ) : null} + {aiCorrectionErrorMessage ? ( +
+ AI SQL correction error: {aiCorrectionErrorMessage} +
+ ) : null} {aiGenerationRationale ? (
AI rationale: {aiGenerationRationale} @@ -1024,7 +1245,9 @@ function getPendingAiSqlExecutionContext(args: { }; } - if (normalizeSqlForAiExecutionContext(pendingAiSqlExecution.sql) !== trimmedSql) { + if ( + normalizeSqlForAiExecutionContext(pendingAiSqlExecution.sql) !== trimmedSql + ) { return { aiQueryRequest: null, shouldAutoGenerateVisualization: false, @@ -1133,6 +1356,13 @@ function adapterSupportsSqlLint(adapter: Adapter): adapter is Adapter & { return typeof adapter.sqlLint === "function"; } +function formatSqlLintDiagnosticForAiCorrection( + diagnostic: AdapterSqlLintDiagnostic, +): string { + const code = diagnostic.code ? ` (${diagnostic.code})` : ""; + return `${diagnostic.message}${code}`; +} + function getDatabaseEngineName(dialect: "postgresql" | "mysql" | "sqlite") { switch (dialect) { case "mysql": @@ -1206,7 +1436,9 @@ function readPersistedSqlEditorStateRow(args: { return null; } - const draftRow = (parsedStorageState as Record)[`s:${rowId}`]; + const draftRow = (parsedStorageState as Record)[ + `s:${rowId}` + ]; if (typeof draftRow !== "object" || draftRow == null) { return null; diff --git a/ui/studio/views/sql/sql-ai-generation.test.ts b/ui/studio/views/sql/sql-ai-generation.test.ts index bafb487c..3e49e3a1 100644 --- a/ui/studio/views/sql/sql-ai-generation.test.ts +++ b/ui/studio/views/sql/sql-ai-generation.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it, vi } from "vitest"; import type { AdapterIntrospectResult } from "@/data"; import { - buildAiSqlGenerationPrompt, buildAiSqlGenerationContext, + buildAiSqlGenerationPrompt, resolveAiSqlGeneration, } from "./sql-ai-generation"; @@ -104,9 +104,48 @@ describe("sql-ai-generation", () => { }); expect(prompt).toContain("Database engine: PostgreSQL"); - expect(prompt).toContain("Use only functions, operators, and casts supported by PostgreSQL."); + expect(prompt).toContain( + "Use only functions, operators, and casts supported by PostgreSQL.", + ); expect(prompt).toContain('"shouldGenerateVisualization":true'); - expect(prompt).toContain("Decide whether the resulting dataset would make an interesting chart."); + expect(prompt).toContain( + "Decide whether the resulting dataset would make an interesting chart.", + ); + }); + + it("includes failed SQL and database errors when correcting generated SQL", async () => { + const aiGenerateSql = vi + .fn<(prompt: string) => Promise>() + .mockResolvedValue( + JSON.stringify({ + rationale: "Uses the correct grouped expression.", + sql: "select o.name, skill, count(*) from public.organizations o left join public.team_members tm on o.id = tm.organization_id cross join lateral unnest(tm.skills) as skill group by o.id, o.name, skill;", + shouldGenerateVisualization: true, + }), + ); + + await resolveAiSqlGeneration({ + activeSchema: "public", + requestAiSqlGeneration: aiGenerateSql, + dialect: "postgresql", + introspection: createIntrospectionFixture(), + previousSql: + "select tm.skills, count(*) from public.team_members tm group by skill;", + queryErrorMessage: + 'column "tm.skills" must appear in the GROUP BY clause or be used in an aggregate function', + request: "show team members by skill", + }); + + expect(aiGenerateSql).toHaveBeenCalledTimes(1); + expect(aiGenerateSql.mock.calls[0]?.[0]).toContain( + "Correct the previous SQL so it runs successfully", + ); + expect(aiGenerateSql.mock.calls[0]?.[0]).toContain( + "Previous SQL statement: select tm.skills, count(*) from public.team_members tm group by skill;", + ); + expect(aiGenerateSql.mock.calls[0]?.[0]).toContain( + 'Database error from that SQL: column "tm.skills" must appear in the GROUP BY clause or be used in an aggregate function', + ); }); it("retries once when the AI response is not valid JSON and then returns parsed SQL", async () => { @@ -186,7 +225,9 @@ describe("sql-ai-generation", () => { }); expect(aiGenerateSql).toHaveBeenCalledTimes(1); - expect(result.sql).toBe("select id, name from public.organizations limit 5;"); + expect(result.sql).toBe( + "select id, name from public.organizations limit 5;", + ); expect(result.rationale).toBe("Counts by organization chart well."); expect(result.shouldGenerateVisualization).toBe(true); }); diff --git a/ui/studio/views/sql/sql-ai-generation.ts b/ui/studio/views/sql/sql-ai-generation.ts index 06674047..a84f59fd 100644 --- a/ui/studio/views/sql/sql-ai-generation.ts +++ b/ui/studio/views/sql/sql-ai-generation.ts @@ -50,6 +50,8 @@ interface ResolveAiSqlGenerationArgs { maxColumnsPerTable?: number; maxTables?: number; now?: Date; + previousSql?: string; + queryErrorMessage?: string; request: string; } @@ -112,12 +114,22 @@ export function buildAiSqlGenerationContext(args: { export function buildAiSqlGenerationPrompt(args: { context: AiSqlGenerationContext; now?: Date; + previousSql?: string; + queryErrorMessage?: string; request: string; }): string { - const { context, now = new Date(), request } = args; + const { + context, + now = new Date(), + previousSql, + queryErrorMessage, + request, + } = args; const databaseEngine = getDatabaseEngineName(context.dialect); const promptTimeZone = - context.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC"; + context.timeZone ?? + Intl.DateTimeFormat().resolvedOptions().timeZone ?? + "UTC"; const tableLines = context.tables.flatMap((table) => { return [ `- ${table.schema}.${table.name}`, @@ -145,15 +157,24 @@ export function buildAiSqlGenerationPrompt(args: { "- If the query returns rows instead of a count or aggregate, include a reasonable LIMIT of 100 or less unless the user explicitly requests another limit.", `- Use only functions, operators, and casts supported by ${databaseEngine}.`, "- Use dialect-appropriate SQL syntax.", + previousSql || queryErrorMessage + ? "- Correct the previous SQL so it runs successfully while preserving the user's original intent." + : null, "- Decide whether the resulting dataset would make an interesting chart.", - '- Set "shouldGenerateVisualization" to true only when the expected result is meaningfully visualizable as a simple chart such as a bar, line, pie, scatter, or time-series view.', + '- Set "shouldGenerateVisualization" to true only when the expected result is meaningfully visualizable as a simple chart such as a bar, line, pie, doughnut, or time-series view.', '- Set "shouldGenerateVisualization" to false for results that are mostly free-form text, unstructured JSON, single values, or otherwise better inspected as a table.', ...getDialectSpecificPromptRules(context.dialect).map((rule) => { return `- ${rule}`; }), "- Never invent tables or columns that are not listed above.", `User request: ${request}`, - ].join("\n"); + previousSql ? `Previous SQL statement: ${previousSql}` : null, + queryErrorMessage + ? `Database error from that SQL: ${queryErrorMessage}` + : null, + ] + .filter((line): line is string => line !== null) + .join("\n"); } export function buildAiSqlGenerationCorrectionPrompt(args: { @@ -176,13 +197,13 @@ export function buildAiSqlGenerationCorrectionPrompt(args: { } = args; return [ - buildAiSqlGenerationPrompt({ context, now, request }), - previousSql - ? `Previous SQL statement: ${previousSql}` - : null, - queryErrorMessage - ? `Database error from that SQL: ${queryErrorMessage}` - : null, + buildAiSqlGenerationPrompt({ + context, + now, + previousSql, + queryErrorMessage, + request, + }), "Your previous response was invalid.", `Original user request: ${request}`, `Previous response: ${responseText}`, @@ -205,6 +226,8 @@ export async function resolveAiSqlGeneration( maxColumnsPerTable, maxTables, now, + previousSql, + queryErrorMessage, request, } = args; const trimmedRequest = request.trim(); @@ -221,13 +244,15 @@ export async function resolveAiSqlGeneration( maxTables, }); - return requestValidatedAiSqlGeneration({ + return await requestValidatedAiSqlGeneration({ requestAiSqlGeneration, buildRetryPrompt: ({ issues, responseText }) => { return buildAiSqlGenerationCorrectionPrompt({ context, issues, now, + previousSql, + queryErrorMessage, request: trimmedRequest, responseText, }); @@ -235,6 +260,8 @@ export async function resolveAiSqlGeneration( prompt: buildAiSqlGenerationPrompt({ context, now, + previousSql, + queryErrorMessage, request: trimmedRequest, }), }); @@ -266,7 +293,9 @@ function parseAiSqlGenerationResponse(responseText: string): { const normalizedResponseText = normalizeAiJsonResponseText(responseText); try { - parsed = JSON.parse(normalizedResponseText) as ParsedAiSqlGenerationResponse; + parsed = JSON.parse( + normalizedResponseText, + ) as ParsedAiSqlGenerationResponse; } catch (error) { return { issues: [ @@ -328,7 +357,8 @@ function parseAiSqlGenerationResponse(responseText: string): { issues: [], value: { rationale: - typeof parsed.rationale === "string" && parsed.rationale.trim().length > 0 + typeof parsed.rationale === "string" && + parsed.rationale.trim().length > 0 ? parsed.rationale.trim() : null, shouldGenerateVisualization: coerceVisualizationDecision( @@ -422,9 +452,7 @@ function getDatabaseEngineName(dialect: SqlEditorDialect): string { function getDialectSpecificPromptRules(dialect: SqlEditorDialect): string[] { switch (dialect) { case "mysql": - return [ - "Do not use PostgreSQL-only syntax like ILIKE or ::type casts.", - ]; + return ["Do not use PostgreSQL-only syntax like ILIKE or ::type casts."]; case "sqlite": return [ "Do not use PostgreSQL schemas, PostgreSQL casts, or MySQL-only functions.", diff --git a/ui/studio/views/sql/sql-result-visualization.test.tsx b/ui/studio/views/sql/sql-result-visualization.test.tsx index d9520e15..b52e0b2d 100644 --- a/ui/studio/views/sql/sql-result-visualization.test.tsx +++ b/ui/studio/views/sql/sql-result-visualization.test.tsx @@ -1,23 +1,15 @@ -// @vitest-environment happy-dom - -import "vitest-canvas-mock"; - -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { StudioLlmError } from "@/data/llm"; import { buildSqlResultVisualizationPrompt, - createSqlResultVisualizationChart, resolveSqlResultVisualization, + validateSqlResultVisualizationConfig, } from "./sql-result-visualization"; describe("sql-result-visualization", () => { - afterEach(() => { - document.body.innerHTML = ""; - }); - - it("builds a Chart.js prompt from the full SQL result set", () => { + it("builds a Bklit chart prompt from the full SQL result set", () => { const prompt = buildSqlResultVisualizationPrompt({ aiQueryRequest: "show me number labels", databaseEngine: "PostgreSQL", @@ -29,8 +21,15 @@ describe("sql-result-visualization", () => { }); expect(prompt).toContain("Generate an appropriate chart"); - expect(prompt).toContain("Chart.js"); - expect(prompt).toContain("Use no external libraries."); + expect(prompt).toContain("Bklit chart components"); + expect(prompt).toContain('"xKey":"label"'); + expect(prompt).toContain('"series":[{"key":"value","label":"Value"}]'); + expect(prompt).toContain('"stacked":false'); + expect(prompt).toContain("For stacked bar and horizontal-bar charts"); + expect(prompt).toContain("horizontal-bar"); + expect(prompt).toContain( + "Use horizontal-bar for ranked categorical results", + ); expect(prompt).toContain('"label":"first"'); expect(prompt).toContain('"label":"second"'); expect(prompt).toContain("Database engine: PostgreSQL"); @@ -49,34 +48,117 @@ describe("sql-result-visualization", () => { expect(prompt).not.toContain("AI query request:"); }); - it("creates working Chart.js instances for a few basic chart types", () => { - const chartTypes = ["bar", "line", "pie"] as const; - - for (const chartType of chartTypes) { - const canvas = document.createElement("canvas"); - document.body.appendChild(canvas); - - const chart = createSqlResultVisualizationChart(canvas, { - data: { - datasets: [ - { - data: [1, 2, 3], - label: "Series", - }, - ], - labels: ["A", "B", "C"], - }, - options: { - responsive: false, - }, - type: chartType, - }); - - expect((chart.config as { type?: string }).type).toBe(chartType); - expect(chart.data.datasets).toHaveLength(1); - - chart.destroy(); - } + it("validates supported Bklit chart configs", () => { + expect( + validateSqlResultVisualizationConfig({ + data: [ + { label: "A", value: 1 }, + { label: "B", value: 2 }, + ], + series: [{ key: "value", label: "Value" }], + type: "bar", + xKey: "label", + }).value, + ).toMatchObject({ type: "bar", xKey: "label" }); + + expect( + validateSqlResultVisualizationConfig({ + data: [ + { organization: "Acme", design: 2, engineering: 3 }, + { organization: "Zen", design: 1, engineering: 5 }, + ], + series: [ + { key: "engineering", label: "Engineering" }, + { key: "design", label: "Design" }, + ], + stacked: true, + type: "bar", + xKey: "organization", + }).value, + ).toMatchObject({ stacked: true, type: "bar", xKey: "organization" }); + + expect( + validateSqlResultVisualizationConfig({ + data: [ + { organization: "Very Long Organization Name", members: 12 }, + { organization: "Another Long Organization", members: 8 }, + ], + series: [{ key: "members", label: "Members" }], + type: "horizontal-bar", + xKey: "organization", + }).value, + ).toMatchObject({ type: "horizontal-bar", xKey: "organization" }); + + expect( + validateSqlResultVisualizationConfig({ + data: [ + { date: "2026-01-01", value: 1 }, + { date: "2026-01-02", value: 2 }, + ], + series: [{ key: "value", label: "Value" }], + type: "line", + xKey: "date", + }).value, + ).toMatchObject({ type: "line", xKey: "date" }); + + expect( + validateSqlResultVisualizationConfig({ + data: [ + { observedAt: "2026-01-01T12:00:00Z", value: 1 }, + { observedAt: 1_779_963_200_000, value: 2 }, + ], + series: [{ key: "value", label: "Value" }], + type: "line", + xKey: "observedAt", + }).value, + ).toMatchObject({ type: "line", xKey: "observedAt" }); + + expect( + validateSqlResultVisualizationConfig({ + data: [{ label: "A", value: 1 }], + labelKey: "label", + type: "doughnut", + valueKey: "value", + }).value, + ).toMatchObject({ labelKey: "label", type: "doughnut", valueKey: "value" }); + }); + + it("rejects configs that Bklit charts cannot render reliably", () => { + expect( + validateSqlResultVisualizationConfig({ + data: [{ label: "A", value: 1 }], + series: [{ key: "value", label: "Value" }], + type: "scatter", + xKey: "label", + }).issues[0]?.message, + ).toContain("Chart type must be one of"); + + expect( + validateSqlResultVisualizationConfig({ + data: [{ label: "A", value: 1 }], + series: [{ key: "value", label: "Value" }], + type: "line", + xKey: "label", + }).issues[0]?.message, + ).toContain("Line chart"); + + expect( + validateSqlResultVisualizationConfig({ + data: [{ date: "12345", value: 1 }], + series: [{ key: "value", label: "Value" }], + type: "line", + xKey: "date", + }).issues[0]?.message, + ).toContain("Line chart"); + + expect( + validateSqlResultVisualizationConfig({ + data: [{ date: "123 456", value: 1 }], + series: [{ key: "value", label: "Value" }], + type: "line", + xKey: "date", + }).issues[0]?.message, + ).toContain("Line chart"); }); it("retries visualization generation up to two times with the latest validation error", async () => { @@ -86,25 +168,20 @@ describe("sql-result-visualization", () => { .mockResolvedValueOnce( JSON.stringify({ config: { - data: { - datasets: [{ data: [1], label: "Series" }], - labels: ["A"], - }, + data: [{ label: "A", value: 1 }], + series: [{ key: "value", label: "Series" }], type: "histogram", + xKey: "label", }, }), ) .mockResolvedValueOnce( JSON.stringify({ config: { - data: { - datasets: [{ data: [1], label: "Series" }], - labels: ["A"], - }, - options: { - responsive: false, - }, + data: [{ label: "A", value: 1 }], + series: [{ key: "value", label: "Series" }], type: "bar", + xKey: "label", }, }), ); @@ -121,10 +198,11 @@ describe("sql-result-visualization", () => { "AI visualization response was not valid JSON", ); expect(requestAiVisualization.mock.calls[2]?.[0]).toContain( - "Chart type must be one of: bar, bubble, doughnut, line, pie, polarArea, radar, scatter.", + "Chart type must be one of: bar, doughnut, horizontal-bar, line, pie.", ); expect(result.didRetry).toBe(true); expect(result.config.type).toBe("bar"); + expect(result.config.series?.[0]?.key).toBe("value"); }); it("retries visualization generation when the provider reports that the output token limit was reached", async () => { @@ -140,14 +218,10 @@ describe("sql-result-visualization", () => { .mockResolvedValueOnce( JSON.stringify({ config: { - data: { - datasets: [{ data: [1], label: "Series" }], - labels: ["A"], - }, - options: { - responsive: false, - }, + data: [{ label: "A", value: 1 }], + series: [{ key: "value", label: "Series" }], type: "bar", + xKey: "label", }, }), ); diff --git a/ui/studio/views/sql/sql-result-visualization.ts b/ui/studio/views/sql/sql-result-visualization.ts index a1e2f0cf..0d31fce2 100644 --- a/ui/studio/views/sql/sql-result-visualization.ts +++ b/ui/studio/views/sql/sql-result-visualization.ts @@ -1,10 +1,3 @@ -import { - Chart, - type ChartConfiguration, - type ChartType, - Colors, -} from "chart.js/auto"; - import { normalizeAiJsonResponseText, requestValidatedAiJsonResponse, @@ -13,29 +6,34 @@ import { const DEFAULT_MAX_VISUALIZATION_CORRECTIONS = 2; const SUPPORTED_CHART_TYPES = [ "bar", - "bubble", "doughnut", + "horizontal-bar", "line", "pie", - "polarArea", - "radar", - "scatter", ] as const; -Chart.register(Colors); - export type SqlResultVisualizationChartType = (typeof SUPPORTED_CHART_TYPES)[number]; +export interface SqlResultVisualizationSeries { + color?: string; + key: string; + label?: string; +} + +export interface SqlResultVisualizationConfig { + data: Record[]; + labelKey?: string; + series?: SqlResultVisualizationSeries[]; + stacked?: boolean; + title?: string; + type: SqlResultVisualizationChartType; + valueKey?: string; + xKey?: string; +} + interface ParsedSqlResultVisualizationResponse { - config?: { - data?: { - datasets?: unknown; - labels?: unknown; - }; - options?: unknown; - type?: unknown; - }; + config?: unknown; } export interface SqlResultVisualizationIssue { @@ -44,14 +42,14 @@ export interface SqlResultVisualizationIssue { | "invalid-config" | "invalid-data" | "invalid-json" - | "invalid-options" + | "invalid-series" | "provider-output-limit"; message: string; responseText?: string; } export interface ResolveSqlResultVisualizationResult { - config: ChartConfiguration; + config: SqlResultVisualizationConfig; didRetry: boolean; responseText: string; } @@ -65,7 +63,7 @@ export function buildSqlResultVisualizationPrompt(args: { const { aiQueryRequest, databaseEngine, querySql, rows } = args; return [ - "Generate an appropriate chart for the following data using the Chart.js library. Use no external libraries.", + "Generate an appropriate chart for the following SQL result data using Prisma Studio's Bklit chart components.", `Database engine: ${databaseEngine}`, `SQL: ${querySql}`, aiQueryRequest ? `AI query request: ${aiQueryRequest}` : null, @@ -73,10 +71,15 @@ export function buildSqlResultVisualizationPrompt(args: { "Full result rows JSON:", JSON.stringify(rows), "Return JSON only. Do not add markdown fences or commentary.", - 'Return this exact top-level shape: {"config":{"type":"bar","data":{"labels":["A"],"datasets":[{"label":"Series","data":[1]}]},"options":{}}}', + 'Return this exact top-level shape: {"config":{"type":"bar","title":"Optional short title","xKey":"label","series":[{"key":"value","label":"Value"}],"stacked":false,"data":[{"label":"A","value":1}]}}', `Supported chart types: ${SUPPORTED_CHART_TYPES.join(", ")}`, - "The config must be valid for new Chart(canvas, config).", - "Do not include functions, callbacks, plugins, dates, Maps, Sets, or references to external libraries.", + "For bar and horizontal-bar charts, provide xKey and one or more series keys with numeric values.", + "Use horizontal-bar for ranked categorical results, top-N lists, and category labels that are long enough to collide on a vertical x-axis.", + "For stacked bar and horizontal-bar charts, set stacked to true and provide one data row per category with separate numeric series fields for each segment. Use stacked bars when the user asks for bars broken down, split, or grouped by a second category.", + "For line charts, provide xKey as an ISO date, ISO datetime, or epoch millisecond field, plus one or more series keys with numeric values.", + "For pie and doughnut charts, provide labelKey and valueKey fields, where valueKey points to numeric values.", + "Use compact, human-readable labels and at most 30 data points unless the result is already smaller.", + "Do not include functions, callbacks, options, plugins, dates as Date objects, Maps, Sets, or references to external libraries.", "Use plain JSON values only.", ] .filter((line): line is string => line !== null) @@ -91,8 +94,14 @@ export function buildSqlResultVisualizationCorrectionPrompt(args: { responseText: string; rows: Record[]; }): string { - const { aiQueryRequest, databaseEngine, issues, querySql, responseText, rows } = - args; + const { + aiQueryRequest, + databaseEngine, + issues, + querySql, + responseText, + rows, + } = args; return [ buildSqlResultVisualizationPrompt({ @@ -145,7 +154,7 @@ export async function resolveSqlResultVisualization(args: { }; }, invalidResponseMessage: - "AI visualization response did not contain a valid Chart.js config.", + "AI visualization response did not contain a valid Bklit chart config.", maxCorrectionRetries, parseResponse: parseSqlResultVisualizationResponse, prompt: buildSqlResultVisualizationPrompt({ @@ -163,22 +172,9 @@ export async function resolveSqlResultVisualization(args: { }; } -export function createSqlResultVisualizationChart( - canvas: HTMLCanvasElement, - config: ChartConfiguration, -) { - return new Chart(canvas, { - ...config, - options: { - maintainAspectRatio: false, - ...config.options, - }, - }); -} - function parseSqlResultVisualizationResponse(responseText: string): { issues: SqlResultVisualizationIssue[]; - value: ChartConfiguration | null; + value: SqlResultVisualizationConfig | null; } { let parsed: ParsedSqlResultVisualizationResponse | null = null; const normalizedResponseText = normalizeAiJsonResponseText(responseText); @@ -203,8 +199,16 @@ function parseSqlResultVisualizationResponse(responseText: string): { }; } - const config = parsed?.config; + return validateSqlResultVisualizationConfig(parsed?.config, responseText); +} +export function validateSqlResultVisualizationConfig( + config: unknown, + responseText?: string, +): { + issues: SqlResultVisualizationIssue[]; + value: SqlResultVisualizationConfig | null; +} { if (!config || typeof config !== "object" || Array.isArray(config)) { return { issues: [ @@ -218,7 +222,9 @@ function parseSqlResultVisualizationResponse(responseText: string): { }; } - if (!isSupportedChartType(config.type)) { + const candidate = config as Partial; + + if (!isSupportedChartType(candidate.type)) { return { issues: [ { @@ -231,18 +237,127 @@ function parseSqlResultVisualizationResponse(responseText: string): { }; } + if (!Array.isArray(candidate.data)) { + return { + issues: [ + { + code: "invalid-data", + message: 'Chart config must include "data" as an array.', + responseText, + }, + ], + value: null, + }; + } + + const data = candidate.data + .filter((row): row is Record => { + return row != null && typeof row === "object" && !Array.isArray(row); + }) + .map((row) => { + return Object.fromEntries( + Object.entries(row).filter(([, value]) => { + return ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" || + value === null + ); + }), + ); + }); + + if (data.length === 0) { + return { + issues: [ + { + code: "invalid-data", + message: "Chart data must contain at least one object row.", + responseText, + }, + ], + value: null, + }; + } + + if (candidate.type === "pie" || candidate.type === "doughnut") { + return validatePieLikeChartConfig({ + candidate, + data, + responseText, + type: candidate.type, + }); + } + + return validateCartesianChartConfig({ + candidate, + data, + responseText, + type: candidate.type, + }); +} + +function validateCartesianChartConfig(args: { + candidate: Partial; + data: Record[]; + responseText?: string; + type: "bar" | "horizontal-bar" | "line"; +}): { + issues: SqlResultVisualizationIssue[]; + value: SqlResultVisualizationConfig | null; +} { + const { candidate, data, responseText, type } = args; + + if (!isNonEmptyString(candidate.xKey)) { + return { + issues: [ + { + code: "invalid-data", + message: `${type} charts must include a non-empty "xKey".`, + responseText, + }, + ], + value: null, + }; + } + + if (!Array.isArray(candidate.series) || candidate.series.length === 0) { + return { + issues: [ + { + code: "invalid-series", + message: `${type} charts must include at least one series.`, + responseText, + }, + ], + value: null, + }; + } + + const series = normalizeSeries(candidate.series); + if (series.length === 0) { + return { + issues: [ + { + code: "invalid-series", + message: "Every chart series must include a non-empty key.", + responseText, + }, + ], + value: null, + }; + } + if ( - !config.data || - typeof config.data !== "object" || - Array.isArray(config.data) || - !Array.isArray(config.data.datasets) + type === "line" && + !data.every((row) => isDateLike(row[candidate.xKey!])) ) { return { issues: [ { code: "invalid-data", message: - 'Chart config must include "data.datasets" as an array.', + 'Line chart "xKey" values must be ISO dates, ISO datetimes, or epoch milliseconds.', responseText, }, ], @@ -250,17 +365,16 @@ function parseSqlResultVisualizationResponse(responseText: string): { }; } - if ( - config.options !== undefined && - (typeof config.options !== "object" || - config.options === null || - Array.isArray(config.options)) - ) { + const hasNumericSeriesValue = series.some((item) => { + return data.some((row) => typeof row[item.key] === "number"); + }); + + if (!hasNumericSeriesValue) { return { issues: [ { - code: "invalid-options", - message: 'Chart config "options" must be a JSON object when present.', + code: "invalid-series", + message: "At least one series key must reference numeric data.", responseText, }, ], @@ -270,13 +384,134 @@ function parseSqlResultVisualizationResponse(responseText: string): { return { issues: [], - value: config as ChartConfiguration, + value: { + data, + series, + stacked: + type === "bar" || type === "horizontal-bar" + ? candidate.stacked === true + : undefined, + title: isNonEmptyString(candidate.title) ? candidate.title : undefined, + type, + xKey: candidate.xKey, + }, }; } -function isSupportedChartType(value: unknown): value is SqlResultVisualizationChartType { +function validatePieLikeChartConfig(args: { + candidate: Partial; + data: Record[]; + responseText?: string; + type: "doughnut" | "pie"; +}): { + issues: SqlResultVisualizationIssue[]; + value: SqlResultVisualizationConfig | null; +} { + const { candidate, data, responseText, type } = args; + + if (!isNonEmptyString(candidate.labelKey)) { + return { + issues: [ + { + code: "invalid-data", + message: `${type} charts must include a non-empty "labelKey".`, + responseText, + }, + ], + value: null, + }; + } + + if (!isNonEmptyString(candidate.valueKey)) { + return { + issues: [ + { + code: "invalid-data", + message: `${type} charts must include a non-empty "valueKey".`, + responseText, + }, + ], + value: null, + }; + } + + const hasNumericValue = data.some( + (row) => typeof row[candidate.valueKey!] === "number", + ); + + if (!hasNumericValue) { + return { + issues: [ + { + code: "invalid-data", + message: '"valueKey" must reference numeric data.', + responseText, + }, + ], + value: null, + }; + } + + return { + issues: [], + value: { + data, + labelKey: candidate.labelKey, + title: isNonEmptyString(candidate.title) ? candidate.title : undefined, + type, + valueKey: candidate.valueKey, + }, + }; +} + +function normalizeSeries(series: unknown[]): SqlResultVisualizationSeries[] { + return series.reduce((items, item) => { + if (!item || typeof item !== "object" || Array.isArray(item)) { + return items; + } + + const candidate = item as Partial; + if (!isNonEmptyString(candidate.key)) { + return items; + } + + items.push({ + ...(isNonEmptyString(candidate.color) ? { color: candidate.color } : {}), + key: candidate.key, + ...(isNonEmptyString(candidate.label) ? { label: candidate.label } : {}), + }); + return items; + }, []); +} + +function isSupportedChartType( + value: unknown, +): value is SqlResultVisualizationChartType { return ( typeof value === "string" && (SUPPORTED_CHART_TYPES as readonly string[]).includes(value) ); } + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function isDateLike(value: unknown): boolean { + if (typeof value === "number") { + return Number.isFinite(value) && value >= 0 && value <= 4_102_444_800_000; + } + + if (typeof value !== "string" || value.trim().length === 0) { + return false; + } + + const trimmedValue = value.trim(); + const isoDateLikePattern = + /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})?)?$/; + + return ( + isoDateLikePattern.test(trimmedValue) && + Number.isFinite(Date.parse(trimmedValue)) + ); +}