diff --git a/README.md b/README.md index 451d61e..264a09c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,14 @@ dependency is **Chart.js** (the chart result view), inlined into that one file. Refactored from a single-file SPA into a fully modular, test-first codebase held at **100% test coverage**. +## Demo & examples + +Try it live on the Antalya demo cluster: **https://antalya.demo.altinity.cloud/sql**. +The [**ontime chart demo**](docs/ONTIME-CHART-DEMO.md) is a ready-made library of 10 +queries (load [`examples/ontime-charts.json`](examples/ontime-charts.json) via +**File ▾ → Replace**) that walks through every chart type and feature against the public +`ontime` flight dataset. + ## How it works ``` diff --git a/docs/ONTIME-CHART-DEMO.md b/docs/ONTIME-CHART-DEMO.md new file mode 100644 index 0000000..bbc4ba5 --- /dev/null +++ b/docs/ONTIME-CHART-DEMO.md @@ -0,0 +1,65 @@ +# Chart demo — the `ontime` flight dataset + +A ready-made **Library** of 10 analytical queries that show off every chart type and +feature in the Altinity SQL Browser, running against the public US flight-history +dataset (`ontime`, ~230M rows, 1987–2025) on the Antalya demo cluster. + +- **Live demo:** **https://antalya.demo.altinity.cloud/sql** +- **The library file:** [`examples/ontime-charts.json`](../examples/ontime-charts.json) + ([raw download](https://raw.githubusercontent.com/Altinity/altinity-sql-browser/main/examples/ontime-charts.json)) +- **Reproduce it:** [`examples/build-ontime-charts.mjs`](../examples/build-ontime-charts.mjs) + regenerates the JSON (it derives each chart's schema key live with + `clickhouse-client --connection antalya`). + +## Load it (≈30 seconds) + +1. Open **https://antalya.demo.altinity.cloud/sql** and sign in (**Continue with Google**, + or use the credentials box). +2. Download [`ontime-charts.json`](https://raw.githubusercontent.com/Altinity/altinity-sql-browser/main/examples/ontime-charts.json) + (right-click → Save link as…). +3. In the header, click **File ▾ → Replace…** and pick the file. The library is renamed + **ontime-charts** and fills with 10 saved queries (confirm the replace if you already + had queries saved). +4. Click any query in the **Library** panel — it runs and opens straight into its chart. + Switch **Table / JSON / Chart** at the top of the results, or change the **Type / X / Y / + Series** dropdowns to re-encode any chart live. + +## What each query demonstrates + +| # | Query | Chart | Feature | +|---|-------|-------|---------| +| 1 | Busiest origin airports — 2023 | Bar (horizontal) | categorical axis; joined to `dim_airports` for readable names; hover any bar (long or short) for its exact value | +| 2 | Flights by month — 2023 | Column | numeric `month` auto-detected as an ordinal axis; K/M-humanised value ticks | +| 3 | Daily flights — 2023 | Line | `Date` axis auto-detected as a time series (~365 points) | +| 4 | Daily on-time rate — 2023 | Area | filled time series (a percentage measure) | +| 5 | Cancellation reasons — 2023 | Pie | share of a small category set, with a legend | +| 6 | Monthly flights by carrier — 2023 | Grouped bars | a **Series** column (carrier) splits each month into per-carrier bars | +| 7 | Average delay breakdown by carrier — 2023 | Multi-measure columns | four measures plotted together (“All measures”) | +| 8 | Daily flights since 2022 | Line | result exceeds the chart cap → a **“first 500 of 1.5K rows”** note (the table keeps them all) | +| 9 | Flights by day of week — 2023 | Column | ordinal `dayofweek` axis | +| 10 | Worst average departure delay by airport — 2023 | Bar (horizontal) | a non-count measure (avg minutes), joined for names | + +Each saved query stores its chart configuration, so it reopens exactly as designed. (Charts +plot the first 500 rows; the full result is always available in the Table view.) + +## Direct links + +Every query is also reachable as a single shareable link — open one and the SQL **and** its +chart configuration are pre-loaded; press **Run**, then the **Chart** tab. (Inside the app, +the **Share** button copies the same kind of link for whatever you're looking at.) + +- **Bar** — [Busiest origin airports — 2023](https://antalya.demo.altinity.cloud/sql#eyJfX2FzYiI6MSwic3FsIjoiU0VMRUNUXG4gICAgYS5EaXNwbGF5QWlycG9ydE5hbWUgQVMgYWlycG9ydCxcbiAgICBjb3VudCgpIEFTIGZsaWdodHNcbkZST00gb250aW1lLmZhY3Rfb250aW1lIEFTIGZcbklOTkVSIEpPSU4gb250aW1lLmRpbV9haXJwb3J0cyBBUyBhXG4gICAgT04gYS5BaXJwb3J0Q29kZSA9IGYuT3JpZ2luQ29kZSBBTkQgYS5Jc0xhdGVzdCA9IDFcbldIRVJFIGYuWWVhciA9IDIwMjNcbkdST1VQIEJZIGFpcnBvcnRcbk9SREVSIEJZIGZsaWdodHMgREVTQ1xuTElNSVQgMTUiLCJjaGFydCI6eyJjZmciOnsidHlwZSI6ImhiYXIiLCJ4IjowLCJ5IjpbMV0sInNlcmllcyI6bnVsbH0sImtleSI6ImFpcnBvcnQ6U3RyaW5nfGZsaWdodHM6VUludDY0In19) +- **Column** — [Flights by month — 2023](https://antalya.demo.altinity.cloud/sql#eyJfX2FzYiI6MSwic3FsIjoiU0VMRUNUIE1vbnRoIEFTIG1vbnRoLCBjb3VudCgpIEFTIGZsaWdodHNcbkZST00gb250aW1lLmZhY3Rfb250aW1lXG5XSEVSRSBZZWFyID0gMjAyM1xuR1JPVVAgQlkgbW9udGhcbk9SREVSIEJZIG1vbnRoIiwiY2hhcnQiOnsiY2ZnIjp7InR5cGUiOiJiYXIiLCJ4IjowLCJ5IjpbMV0sInNlcmllcyI6bnVsbH0sImtleSI6Im1vbnRoOlVJbnQ4fGZsaWdodHM6VUludDY0In19) +- **Line** — [Daily flights — 2023](https://antalya.demo.altinity.cloud/sql#eyJfX2FzYiI6MSwic3FsIjoiU0VMRUNUIEZsaWdodERhdGUgQVMgZGF0ZSwgY291bnQoKSBBUyBmbGlnaHRzXG5GUk9NIG9udGltZS5mYWN0X29udGltZVxuV0hFUkUgWWVhciA9IDIwMjNcbkdST1VQIEJZIGRhdGVcbk9SREVSIEJZIGRhdGUiLCJjaGFydCI6eyJjZmciOnsidHlwZSI6ImxpbmUiLCJ4IjowLCJ5IjpbMV0sInNlcmllcyI6bnVsbH0sImtleSI6ImRhdGU6RGF0ZXxmbGlnaHRzOlVJbnQ2NCJ9fQ==) +- **Area** — [Daily on-time rate — 2023](https://antalya.demo.altinity.cloud/sql#eyJfX2FzYiI6MSwic3FsIjoiU0VMRUNUXG4gICAgRmxpZ2h0RGF0ZSBBUyBkYXRlLFxuICAgIHJvdW5kKDEwMCAqIGNvdW50SWYoQXJyRGVsMTUgPSAwKSAvIGNvdW50KCksIDEpIEFTIG9uX3RpbWVfcGN0XG5GUk9NIG9udGltZS5mYWN0X29udGltZVxuV0hFUkUgWWVhciA9IDIwMjNcbkdST1VQIEJZIGRhdGVcbk9SREVSIEJZIGRhdGUiLCJjaGFydCI6eyJjZmciOnsidHlwZSI6ImFyZWEiLCJ4IjowLCJ5IjpbMV0sInNlcmllcyI6bnVsbH0sImtleSI6ImRhdGU6RGF0ZXxvbl90aW1lX3BjdDpGbG9hdDY0In19) +- **Pie** — [Cancellation reasons — 2023](https://antalya.demo.altinity.cloud/sql#eyJfX2FzYiI6MSwic3FsIjoiU0VMRUNUXG4gICAgbXVsdGlJZihDYW5jZWxsYXRpb25Db2RlID0gJ0EnLCAnQ2FycmllcicsXG4gICAgICAgICAgICBDYW5jZWxsYXRpb25Db2RlID0gJ0InLCAnV2VhdGhlcicsXG4gICAgICAgICAgICBDYW5jZWxsYXRpb25Db2RlID0gJ0MnLCAnTmF0aW9uYWwgQWlyIFN5c3RlbScsXG4gICAgICAgICAgICBDYW5jZWxsYXRpb25Db2RlID0gJ0QnLCAnU2VjdXJpdHknLCAnT3RoZXInKSBBUyByZWFzb24sXG4gICAgY291bnQoKSBBUyBjYW5jZWxsYXRpb25zXG5GUk9NIG9udGltZS5mYWN0X29udGltZVxuV0hFUkUgWWVhciA9IDIwMjMgQU5EIENhbmNlbGxlZCA9IDFcbkdST1VQIEJZIHJlYXNvblxuT1JERVIgQlkgY2FuY2VsbGF0aW9ucyBERVNDIiwiY2hhcnQiOnsiY2ZnIjp7InR5cGUiOiJwaWUiLCJ4IjowLCJ5IjpbMV0sInNlcmllcyI6bnVsbH0sImtleSI6InJlYXNvbjpTdHJpbmd8Y2FuY2VsbGF0aW9uczpVSW50NjQifX0=) +- **Grouped columns** — [Monthly flights by carrier — 2023](https://antalya.demo.altinity.cloud/sql#eyJfX2FzYiI6MSwic3FsIjoiU0VMRUNUXG4gICAgTW9udGggQVMgbW9udGgsXG4gICAgQ2FycmllciBBUyBjYXJyaWVyLFxuICAgIGNvdW50KCkgQVMgZmxpZ2h0c1xuRlJPTSBvbnRpbWUuZmFjdF9vbnRpbWVcbldIRVJFIFllYXIgPSAyMDIzIEFORCBDYXJyaWVyIElOICgnV04nLCAnQUEnLCAnREwnLCAnVUEnKVxuR1JPVVAgQlkgbW9udGgsIGNhcnJpZXJcbk9SREVSIEJZIG1vbnRoLCBjYXJyaWVyIiwiY2hhcnQiOnsiY2ZnIjp7InR5cGUiOiJiYXIiLCJ4IjowLCJ5IjpbMl0sInNlcmllcyI6MX0sImtleSI6Im1vbnRoOlVJbnQ4fGNhcnJpZXI6TG93Q2FyZGluYWxpdHkoU3RyaW5nKXxmbGlnaHRzOlVJbnQ2NCJ9fQ==) +- **Multi-measure columns** — [Average delay breakdown by carrier — 2023](https://antalya.demo.altinity.cloud/sql#eyJfX2FzYiI6MSwic3FsIjoiU0VMRUNUXG4gICAgQ2FycmllciBBUyBjYXJyaWVyLFxuICAgIHJvdW5kKGF2ZyhDYXJyaWVyRGVsYXkpLCAxKSBBUyBjYXJyaWVyX2RlbGF5LFxuICAgIHJvdW5kKGF2ZyhXZWF0aGVyRGVsYXkpLCAxKSBBUyB3ZWF0aGVyX2RlbGF5LFxuICAgIHJvdW5kKGF2ZyhOQVNEZWxheSksIDEpIEFTIG5hc19kZWxheSxcbiAgICByb3VuZChhdmcoTGF0ZUFpcmNyYWZ0RGVsYXkpLCAxKSBBUyBsYXRlX2FpcmNyYWZ0X2RlbGF5XG5GUk9NIG9udGltZS5mYWN0X29udGltZVxuV0hFUkUgWWVhciA9IDIwMjMgQU5EIEFyckRlbDE1ID0gMVxuR1JPVVAgQlkgY2FycmllclxuT1JERVIgQlkgY2Fycmllcl9kZWxheSBERVNDXG5MSU1JVCAxMiIsImNoYXJ0Ijp7ImNmZyI6eyJ0eXBlIjoiYmFyIiwieCI6MCwieSI6WzEsMiwzLDRdLCJzZXJpZXMiOm51bGx9LCJrZXkiOiJjYXJyaWVyOkxvd0NhcmRpbmFsaXR5KFN0cmluZyl8Y2Fycmllcl9kZWxheTpOdWxsYWJsZShGbG9hdDY0KXx3ZWF0aGVyX2RlbGF5Ok51bGxhYmxlKEZsb2F0NjQpfG5hc19kZWxheTpOdWxsYWJsZShGbG9hdDY0KXxsYXRlX2FpcmNyYWZ0X2RlbGF5Ok51bGxhYmxlKEZsb2F0NjQpIn19) +- **Line (capped)** — [Daily flights since 2022](https://antalya.demo.altinity.cloud/sql#eyJfX2FzYiI6MSwic3FsIjoiU0VMRUNUIEZsaWdodERhdGUgQVMgZGF0ZSwgY291bnQoKSBBUyBmbGlnaHRzXG5GUk9NIG9udGltZS5mYWN0X29udGltZVxuV0hFUkUgRmxpZ2h0RGF0ZSA+PSAnMjAyMi0wMS0wMSdcbkdST1VQIEJZIGRhdGVcbk9SREVSIEJZIGRhdGUiLCJjaGFydCI6eyJjZmciOnsidHlwZSI6ImxpbmUiLCJ4IjowLCJ5IjpbMV0sInNlcmllcyI6bnVsbH0sImtleSI6ImRhdGU6RGF0ZXxmbGlnaHRzOlVJbnQ2NCJ9fQ==) +- **Column** — [Flights by day of week — 2023](https://antalya.demo.altinity.cloud/sql#eyJfX2FzYiI6MSwic3FsIjoiU0VMRUNUIERheU9mV2VlayBBUyBkYXlvZndlZWssIGNvdW50KCkgQVMgZmxpZ2h0c1xuRlJPTSBvbnRpbWUuZmFjdF9vbnRpbWVcbldIRVJFIFllYXIgPSAyMDIzXG5HUk9VUCBCWSBkYXlvZndlZWtcbk9SREVSIEJZIGRheW9md2VlayIsImNoYXJ0Ijp7ImNmZyI6eyJ0eXBlIjoiYmFyIiwieCI6MCwieSI6WzFdLCJzZXJpZXMiOm51bGx9LCJrZXkiOiJkYXlvZndlZWs6VUludDh8ZmxpZ2h0czpVSW50NjQifX0=) +- **Bar** — [Worst average departure delay by airport — 2023](https://antalya.demo.altinity.cloud/sql#eyJfX2FzYiI6MSwic3FsIjoiU0VMRUNUXG4gICAgYS5EaXNwbGF5QWlycG9ydE5hbWUgQVMgYWlycG9ydCxcbiAgICByb3VuZChhdmcoZi5EZXBEZWxheU1pbnV0ZXMpLCAxKSBBUyBhdmdfZGVwX2RlbGF5XG5GUk9NIG9udGltZS5mYWN0X29udGltZSBBUyBmXG5JTk5FUiBKT0lOIG9udGltZS5kaW1fYWlycG9ydHMgQVMgYVxuICAgIE9OIGEuQWlycG9ydENvZGUgPSBmLk9yaWdpbkNvZGUgQU5EIGEuSXNMYXRlc3QgPSAxXG5XSEVSRSBmLlllYXIgPSAyMDIzXG5HUk9VUCBCWSBhaXJwb3J0XG5IQVZJTkcgY291bnQoKSA+PSAxMDAwMFxuT1JERVIgQlkgYXZnX2RlcF9kZWxheSBERVNDXG5MSU1JVCAxNSIsImNoYXJ0Ijp7ImNmZyI6eyJ0eXBlIjoiaGJhciIsIngiOjAsInkiOlsxXSwic2VyaWVzIjpudWxsfSwia2V5IjoiYWlycG9ydDpTdHJpbmd8YXZnX2RlcF9kZWxheTpOdWxsYWJsZShGbG9hdDY0KSJ9fQ==) + +## Tables used + +- `ontime.fact_ontime` — one row per US domestic flight (dates, carrier, origin/dest, delays, cancellations, …). +- `ontime.dim_airports` — airport reference data; joined on `AirportCode = OriginCode AND IsLatest = 1` for human-readable airport names. diff --git a/examples/build-ontime-charts.mjs b/examples/build-ontime-charts.mjs new file mode 100644 index 0000000..858c5eb --- /dev/null +++ b/examples/build-ontime-charts.mjs @@ -0,0 +1,198 @@ +// Generator for examples/ontime-charts.json — a saved-queries "Library" file for +// the Altinity SQL Browser that demonstrates every chart feature against the +// public `ontime` flights dataset on the antalya cluster. +// +// Why a generator: the browser only restores a saved chart config when the +// entry's `chart.key` exactly equals schemaKey(resultColumns) = "name:type|…" +// (see src/ui/results.js chartCfgFor / src/core/chart-data.js schemaKey). +// Hand-writing those type strings is error-prone, so we derive each key live +// from `DESCRIBE ()` against the real cluster. +// +// Run: node examples/build-ontime-charts.mjs (needs `clickhouse-client --connection antalya`) +// Out: examples/ontime-charts.json + +import { execFileSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); +const CONNECTION = 'antalya'; + +// Each spec: a query + the chart we want it to open with. `cfg` matches the +// app's shape { type, x, y:[...], series }; x/series are column indices, y a +// list of measure-column indices. `view:'chart'` makes a click open the chart. +const SPECS = [ + { + name: 'Busiest origin airports — 2023', + description: 'Top 15 departure airports by flight count (joined to dim_airports for readable names). Horizontal Bar — hover any bar, long or short, to read its exact value.', + cfg: { type: 'hbar', x: 0, y: [1], series: null }, + sql: `SELECT + a.DisplayAirportName AS airport, + count() AS flights +FROM ontime.fact_ontime AS f +INNER JOIN ontime.dim_airports AS a + ON a.AirportCode = f.OriginCode AND a.IsLatest = 1 +WHERE f.Year = 2023 +GROUP BY airport +ORDER BY flights DESC +LIMIT 15`, + }, + { + name: 'Flights by month — 2023', + description: 'Monthly US flight volume. A numeric column named "month" is detected as an ordinal axis → vertical Column chart, with K/M-humanised value ticks.', + cfg: { type: 'bar', x: 0, y: [1], series: null }, + sql: `SELECT Month AS month, count() AS flights +FROM ontime.fact_ontime +WHERE Year = 2023 +GROUP BY month +ORDER BY month`, + }, + { + name: 'Daily flights — 2023', + description: 'One point per day across 2023 (~365 rows). A Date X axis is auto-detected as a time series → Line chart.', + cfg: { type: 'line', x: 0, y: [1], series: null }, + sql: `SELECT FlightDate AS date, count() AS flights +FROM ontime.fact_ontime +WHERE Year = 2023 +GROUP BY date +ORDER BY date`, + }, + { + name: 'Daily on-time rate — 2023', + description: 'Share of flights arriving on time (< 15 min late) per day, as a percentage. Rendered as a filled Area chart.', + cfg: { type: 'area', x: 0, y: [1], series: null }, + sql: `SELECT + FlightDate AS date, + round(100 * countIf(ArrDel15 = 0) / count(), 1) AS on_time_pct +FROM ontime.fact_ontime +WHERE Year = 2023 +GROUP BY date +ORDER BY date`, + }, + { + name: 'Cancellation reasons — 2023', + description: 'Why flights were cancelled in 2023 (carrier / weather / national air system / security). A small categorical breakdown → Pie chart with a legend.', + cfg: { type: 'pie', x: 0, y: [1], series: null }, + sql: `SELECT + multiIf(CancellationCode = 'A', 'Carrier', + CancellationCode = 'B', 'Weather', + CancellationCode = 'C', 'National Air System', + CancellationCode = 'D', 'Security', 'Other') AS reason, + count() AS cancellations +FROM ontime.fact_ontime +WHERE Year = 2023 AND Cancelled = 1 +GROUP BY reason +ORDER BY cancellations DESC`, + }, + { + name: 'Monthly flights by carrier — 2023', + description: 'Flights per month split across four major carriers (WN, AA, DL, UA). The "carrier" column is used as the Series, producing grouped bars with a per-carrier legend.', + cfg: { type: 'bar', x: 0, y: [2], series: 1 }, + sql: `SELECT + Month AS month, + Carrier AS carrier, + count() AS flights +FROM ontime.fact_ontime +WHERE Year = 2023 AND Carrier IN ('WN', 'AA', 'DL', 'UA') +GROUP BY month, carrier +ORDER BY month, carrier`, + }, + { + name: 'Average delay breakdown by carrier — 2023', + description: 'Mean minutes of each delay cause (carrier, weather, NAS, late aircraft) for delayed flights, per carrier. Four measures plotted at once ("All measures") as grouped columns.', + cfg: { type: 'bar', x: 0, y: [1, 2, 3, 4], series: null }, + sql: `SELECT + Carrier AS carrier, + round(avg(CarrierDelay), 1) AS carrier_delay, + round(avg(WeatherDelay), 1) AS weather_delay, + round(avg(NASDelay), 1) AS nas_delay, + round(avg(LateAircraftDelay), 1) AS late_aircraft_delay +FROM ontime.fact_ontime +WHERE Year = 2023 AND ArrDel15 = 1 +GROUP BY carrier +ORDER BY carrier_delay DESC +LIMIT 12`, + }, + { + name: 'Daily flights since 2022', + description: 'Every day from 2022 onward (~1,460 points). The chart plots the first 500 and shows a "first 500 of N rows" note — the table view still has them all.', + cfg: { type: 'line', x: 0, y: [1], series: null }, + sql: `SELECT FlightDate AS date, count() AS flights +FROM ontime.fact_ontime +WHERE FlightDate >= '2022-01-01' +GROUP BY date +ORDER BY date`, + }, + { + name: 'Flights by day of week — 2023', + description: 'Volume by day of week (1 = Monday … 7 = Sunday). "dayofweek" is recognised as an ordinal axis → Column chart.', + cfg: { type: 'bar', x: 0, y: [1], series: null }, + sql: `SELECT DayOfWeek AS dayofweek, count() AS flights +FROM ontime.fact_ontime +WHERE Year = 2023 +GROUP BY dayofweek +ORDER BY dayofweek`, + }, + { + name: 'Worst average departure delay by airport — 2023', + description: 'Airports with the highest mean departure delay (minutes) among those with ≥ 10,000 departures in 2023. Horizontal Bar of a non-count measure, joined for names.', + cfg: { type: 'hbar', x: 0, y: [1], series: null }, + sql: `SELECT + a.DisplayAirportName AS airport, + round(avg(f.DepDelayMinutes), 1) AS avg_dep_delay +FROM ontime.fact_ontime AS f +INNER JOIN ontime.dim_airports AS a + ON a.AirportCode = f.OriginCode AND a.IsLatest = 1 +WHERE f.Year = 2023 +GROUP BY airport +HAVING count() >= 10000 +ORDER BY avg_dep_delay DESC +LIMIT 15`, + }, +]; + +const ch = (query) => + execFileSync('clickhouse-client', ['--connection', CONNECTION, '--query', query], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + }); + +// schemaKey == columns.map(c => c.name + ':' + c.type).join('|'), derived from +// DESCRIBE so it matches exactly what the app receives at run time. +function schemaKey(sql) { + const out = ch(`DESCRIBE (${sql})`); + return out + .split('\n') + .filter((l) => l.trim()) + .map((l) => { const [name, type] = l.split('\t'); return `${name}:${type}`; }) + .join('|'); +} + +const resultRows = (sql) => Number(ch(`SELECT count() FROM (${sql})`).trim()); + +const queries = SPECS.map((s, i) => { + const key = schemaKey(s.sql); + const rows = resultRows(s.sql); + console.log(`#${i + 1} ${s.cfg.type.padEnd(4)} rows=${String(rows).padStart(5)} key=${key}`); + return { + id: 's' + (i + 1), + name: s.name, + sql: s.sql, + favorite: false, + description: s.description, + chart: { cfg: s.cfg, key }, + view: 'chart', + }; +}); + +const doc = { + format: 'altinity-sql-browser/saved-queries', + version: 1, + exportedAt: new Date().toISOString(), + queries, +}; + +const outPath = resolve(here, 'ontime-charts.json'); +writeFileSync(outPath, JSON.stringify(doc, null, 2) + '\n'); +console.log(`\nwrote ${outPath} (${queries.length} queries)`); diff --git a/examples/ontime-charts.json b/examples/ontime-charts.json new file mode 100644 index 0000000..64c5ea7 --- /dev/null +++ b/examples/ontime-charts.json @@ -0,0 +1,200 @@ +{ + "format": "altinity-sql-browser/saved-queries", + "version": 1, + "exportedAt": "2026-06-24T13:22:11.940Z", + "queries": [ + { + "id": "s1", + "name": "Busiest origin airports — 2023", + "sql": "SELECT\n a.DisplayAirportName AS airport,\n count() AS flights\nFROM ontime.fact_ontime AS f\nINNER JOIN ontime.dim_airports AS a\n ON a.AirportCode = f.OriginCode AND a.IsLatest = 1\nWHERE f.Year = 2023\nGROUP BY airport\nORDER BY flights DESC\nLIMIT 15", + "favorite": false, + "description": "Top 15 departure airports by flight count (joined to dim_airports for readable names). Horizontal Bar — hover any bar, long or short, to read its exact value.", + "chart": { + "cfg": { + "type": "hbar", + "x": 0, + "y": [ + 1 + ], + "series": null + }, + "key": "airport:String|flights:UInt64" + }, + "view": "chart" + }, + { + "id": "s2", + "name": "Flights by month — 2023", + "sql": "SELECT Month AS month, count() AS flights\nFROM ontime.fact_ontime\nWHERE Year = 2023\nGROUP BY month\nORDER BY month", + "favorite": false, + "description": "Monthly US flight volume. A numeric column named \"month\" is detected as an ordinal axis → vertical Column chart, with K/M-humanised value ticks.", + "chart": { + "cfg": { + "type": "bar", + "x": 0, + "y": [ + 1 + ], + "series": null + }, + "key": "month:UInt8|flights:UInt64" + }, + "view": "chart" + }, + { + "id": "s3", + "name": "Daily flights — 2023", + "sql": "SELECT FlightDate AS date, count() AS flights\nFROM ontime.fact_ontime\nWHERE Year = 2023\nGROUP BY date\nORDER BY date", + "favorite": false, + "description": "One point per day across 2023 (~365 rows). A Date X axis is auto-detected as a time series → Line chart.", + "chart": { + "cfg": { + "type": "line", + "x": 0, + "y": [ + 1 + ], + "series": null + }, + "key": "date:Date|flights:UInt64" + }, + "view": "chart" + }, + { + "id": "s4", + "name": "Daily on-time rate — 2023", + "sql": "SELECT\n FlightDate AS date,\n round(100 * countIf(ArrDel15 = 0) / count(), 1) AS on_time_pct\nFROM ontime.fact_ontime\nWHERE Year = 2023\nGROUP BY date\nORDER BY date", + "favorite": false, + "description": "Share of flights arriving on time (< 15 min late) per day, as a percentage. Rendered as a filled Area chart.", + "chart": { + "cfg": { + "type": "area", + "x": 0, + "y": [ + 1 + ], + "series": null + }, + "key": "date:Date|on_time_pct:Float64" + }, + "view": "chart" + }, + { + "id": "s5", + "name": "Cancellation reasons — 2023", + "sql": "SELECT\n multiIf(CancellationCode = 'A', 'Carrier',\n CancellationCode = 'B', 'Weather',\n CancellationCode = 'C', 'National Air System',\n CancellationCode = 'D', 'Security', 'Other') AS reason,\n count() AS cancellations\nFROM ontime.fact_ontime\nWHERE Year = 2023 AND Cancelled = 1\nGROUP BY reason\nORDER BY cancellations DESC", + "favorite": false, + "description": "Why flights were cancelled in 2023 (carrier / weather / national air system / security). A small categorical breakdown → Pie chart with a legend.", + "chart": { + "cfg": { + "type": "pie", + "x": 0, + "y": [ + 1 + ], + "series": null + }, + "key": "reason:String|cancellations:UInt64" + }, + "view": "chart" + }, + { + "id": "s6", + "name": "Monthly flights by carrier — 2023", + "sql": "SELECT\n Month AS month,\n Carrier AS carrier,\n count() AS flights\nFROM ontime.fact_ontime\nWHERE Year = 2023 AND Carrier IN ('WN', 'AA', 'DL', 'UA')\nGROUP BY month, carrier\nORDER BY month, carrier", + "favorite": false, + "description": "Flights per month split across four major carriers (WN, AA, DL, UA). The \"carrier\" column is used as the Series, producing grouped bars with a per-carrier legend.", + "chart": { + "cfg": { + "type": "bar", + "x": 0, + "y": [ + 2 + ], + "series": 1 + }, + "key": "month:UInt8|carrier:LowCardinality(String)|flights:UInt64" + }, + "view": "chart" + }, + { + "id": "s7", + "name": "Average delay breakdown by carrier — 2023", + "sql": "SELECT\n Carrier AS carrier,\n round(avg(CarrierDelay), 1) AS carrier_delay,\n round(avg(WeatherDelay), 1) AS weather_delay,\n round(avg(NASDelay), 1) AS nas_delay,\n round(avg(LateAircraftDelay), 1) AS late_aircraft_delay\nFROM ontime.fact_ontime\nWHERE Year = 2023 AND ArrDel15 = 1\nGROUP BY carrier\nORDER BY carrier_delay DESC\nLIMIT 12", + "favorite": false, + "description": "Mean minutes of each delay cause (carrier, weather, NAS, late aircraft) for delayed flights, per carrier. Four measures plotted at once (\"All measures\") as grouped columns.", + "chart": { + "cfg": { + "type": "bar", + "x": 0, + "y": [ + 1, + 2, + 3, + 4 + ], + "series": null + }, + "key": "carrier:LowCardinality(String)|carrier_delay:Nullable(Float64)|weather_delay:Nullable(Float64)|nas_delay:Nullable(Float64)|late_aircraft_delay:Nullable(Float64)" + }, + "view": "chart" + }, + { + "id": "s8", + "name": "Daily flights since 2022", + "sql": "SELECT FlightDate AS date, count() AS flights\nFROM ontime.fact_ontime\nWHERE FlightDate >= '2022-01-01'\nGROUP BY date\nORDER BY date", + "favorite": false, + "description": "Every day from 2022 onward (~1,460 points). The chart plots the first 500 and shows a \"first 500 of N rows\" note — the table view still has them all.", + "chart": { + "cfg": { + "type": "line", + "x": 0, + "y": [ + 1 + ], + "series": null + }, + "key": "date:Date|flights:UInt64" + }, + "view": "chart" + }, + { + "id": "s9", + "name": "Flights by day of week — 2023", + "sql": "SELECT DayOfWeek AS dayofweek, count() AS flights\nFROM ontime.fact_ontime\nWHERE Year = 2023\nGROUP BY dayofweek\nORDER BY dayofweek", + "favorite": false, + "description": "Volume by day of week (1 = Monday … 7 = Sunday). \"dayofweek\" is recognised as an ordinal axis → Column chart.", + "chart": { + "cfg": { + "type": "bar", + "x": 0, + "y": [ + 1 + ], + "series": null + }, + "key": "dayofweek:UInt8|flights:UInt64" + }, + "view": "chart" + }, + { + "id": "s10", + "name": "Worst average departure delay by airport — 2023", + "sql": "SELECT\n a.DisplayAirportName AS airport,\n round(avg(f.DepDelayMinutes), 1) AS avg_dep_delay\nFROM ontime.fact_ontime AS f\nINNER JOIN ontime.dim_airports AS a\n ON a.AirportCode = f.OriginCode AND a.IsLatest = 1\nWHERE f.Year = 2023\nGROUP BY airport\nHAVING count() >= 10000\nORDER BY avg_dep_delay DESC\nLIMIT 15", + "favorite": false, + "description": "Airports with the highest mean departure delay (minutes) among those with ≥ 10,000 departures in 2023. Horizontal Bar of a non-count measure, joined for names.", + "chart": { + "cfg": { + "type": "hbar", + "x": 0, + "y": [ + 1 + ], + "series": null + }, + "key": "airport:String|avg_dep_delay:Nullable(Float64)" + }, + "view": "chart" + } + ] +} diff --git a/src/core/chart-data.js b/src/core/chart-data.js index 5d45cc6..62d93c3 100644 --- a/src/core/chart-data.js +++ b/src/core/chart-data.js @@ -270,6 +270,28 @@ export function buildChartData(columns, rows, cfg) { return { labels: cats.map(chartLabel), datasets }; } +/** + * Correct a Chart.js pointer event for the page's CSS `zoom`. Chart.js resolves + * pointer hits from the event's `offsetX/offsetY`, which the browser reports in + * *zoomed* pixels, whereas the chart draws in an unzoomed coordinate system — so + * under `html { zoom: S }` every hover lands S× too far along the axis (long bars + * read past their end; short bars near the origin never register, and the tooltip + * caret drifts right). Dividing the resolved x/y by the live zoom scale realigns + * them, fixing bar/column/pie alike. No-op when S is 1 (unzoomed) or the event + * carries no numeric coordinates. Mutates and returns `e` (the normalized event + * object Chart.js hands to its controller). Pure: no DOM access — the caller + * supplies `scale` (from `zoomScale(canvas)`). + */ +export function unzoomChartEvent(e, scale) { + if (e && typeof e.x === 'number' && typeof e.y === 'number' && scale && scale !== 1) { + e.x /= scale; + e.y /= scale; + e.offsetX = e.x; + e.offsetY = e.y; + } + return e; +} + const withAlpha = (hex, frac) => { // #RRGGBB → rgba(...) at `frac` opacity. Non-hex passes through unchanged. const m = /^#([0-9a-fA-F]{6})$/.exec(hex); diff --git a/src/core/format.js b/src/core/format.js index dce19bd..faca30f 100644 --- a/src/core/format.js +++ b/src/core/format.js @@ -50,6 +50,19 @@ export function sqlString(s) { return "'" + String(s).replace(/\\/g, '\\\\').replace(/'/g, "''") + "'"; } +/** + * Terminate `sql` so a programmatic full-replace (Format / Insert DDL) leaves the + * caret on empty space rather than at the end of the last token. The editor's + * autocomplete needs ≥1 word char immediately before the caret, so without this + * a freshly-formatted query pops an irrelevant dropdown on its trailing word. + * Appends a single newline only when the text doesn't already end in whitespace + * or ';'. Pure. + */ +export function withStatementBreak(sql) { + const s = String(sql || ''); + return s === '' || /[\s;]$/.test(s) ? s : s + '\n'; +} + /** * Derive a short display name for a saved query: "Query · " when a * FROM clause is present, else the first 48 chars of the collapsed SQL. diff --git a/src/state.js b/src/state.js index 115b049..cc12805 100644 --- a/src/state.js +++ b/src/state.js @@ -69,6 +69,9 @@ export function createState(read = { loadJSON, loadStr }) { // file Save/Replace/New) is session-only and resets on reload. libraryName: read.loadStr(KEYS.libraryName, DEFAULT_LIBRARY_NAME), libraryDirty: false, + // Transient search text for the Library/History side panel (session-only, + // cleared on a tab switch); never persisted. + libraryFilter: '', shortcutsOpen: false, }; } @@ -165,6 +168,26 @@ export function sortedSaved(state) { .map(([q]) => q); } +/** + * Filter saved queries by a free-text query (case-insensitive substring over + * name, description and SQL). Blank query → the list returned unchanged. Pure. + */ +export function filterSaved(list, query) { + const q = String(query || '').trim().toLowerCase(); + if (!q) return list; + return list.filter((it) => + (it.name || '').toLowerCase().includes(q) || + (it.description || '').toLowerCase().includes(q) || + (it.sql || '').toLowerCase().includes(q)); +} + +/** Filter history entries by a free-text query (case-insensitive over SQL). Pure. */ +export function filterHistory(list, query) { + const q = String(query || '').trim().toLowerCase(); + if (!q) return list; + return list.filter((ent) => (ent.sql || '').toLowerCase().includes(q)); +} + /** * Merge imported queries into savedQueries (dedupe by content, update by id, * else add). Returns { added, updated, skipped }. diff --git a/src/styles.css b/src/styles.css index 3d557d5..e5aa338 100644 --- a/src/styles.css +++ b/src/styles.css @@ -701,6 +701,35 @@ body { outline: none; } .search-wrap input:focus { border-color: var(--accent); } + +/* Library / History search box (mirrors .schema-search; collapses when empty). */ +.saved-search { + padding: 8px 10px; + border-bottom: 1px solid var(--border-faint); + position: relative; + flex-shrink: 0; +} +.saved-search:empty { display: none; } +.sv-search-icon { + position: absolute; left: 18px; top: 50%; transform: translateY(-50%); + color: var(--fg-faint); pointer-events: none; display: flex; +} +.sv-search-input { + display: block; width: 100%; height: 26px; box-sizing: border-box; + padding: 0 26px; + background: var(--bg-input); border: 1px solid var(--border); + border-radius: 5px; color: var(--fg); + font-size: 11.5px; font-family: inherit; outline: none; +} +.sv-search-input:focus { border-color: var(--accent); } +.sv-search-clear { + position: absolute; right: 16px; top: 50%; transform: translateY(-50%); + width: 18px; height: 18px; border: none; border-radius: 4px; + background: transparent; color: var(--fg-faint); cursor: pointer; + display: flex; align-items: center; justify-content: center; padding: 0; +} +.sv-search-clear:hover { background: var(--bg-chip); color: var(--fg); } + .schema-list { flex: 1; overflow: auto; padding: 4px 0; } .schema-empty { padding: 24px 14px; color: var(--fg-faint); font-size: 12px; text-align: center; diff --git a/src/ui/app.js b/src/ui/app.js index c4823da..16d9d96 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -11,7 +11,7 @@ import { } from '../state.js'; import { saveJSON, saveStr } from '../core/storage.js'; import { decodeJwtPayload, isTokenExpired } from '../core/jwt.js'; -import { sqlString, inferQueryName, shortVersion, userShortName } from '../core/format.js'; +import { sqlString, inferQueryName, shortVersion, userShortName, withStatementBreak } from '../core/format.js'; import { resolveTarget } from '../core/target.js'; import { toTSV, toCSV } from '../core/export.js'; import { newResult, applyStreamLine } from '../core/stream.js'; @@ -449,7 +449,9 @@ export function createApp(env = {}) { try { const json = await ch.queryJson(chCtx, 'SELECT formatQuery(' + sqlString(sql) + ') AS q FORMAT JSON'); const q = (json.data && json.data[0] && json.data[0].q) || ''; - if (q) replaceEditor(app, q); + // Terminate so the caret lands past the last token — otherwise the input + // event from the replace re-opens autocomplete on the trailing word. + if (q) replaceEditor(app, withStatementBreak(q)); } catch (e) { flashToast('Format failed: ' + String((e && e.message) || e), { document: doc }); } @@ -711,8 +713,9 @@ export function renderApp(app, helpers) { app.dom.schemaList); app.dom.savedTabsRow = h('div', { class: 'side-tabs' }); + app.dom.savedSearch = h('div', { class: 'saved-search' }); app.dom.savedList = h('div', { class: 'saved-list' }); - const savedPane = h('div', { class: 'side-pane saved-pane', style: { flex: '1', minHeight: '0' } }, app.dom.savedTabsRow, app.dom.savedList); + const savedPane = h('div', { class: 'side-pane saved-pane', style: { flex: '1', minHeight: '0' } }, app.dom.savedTabsRow, app.dom.savedSearch, app.dom.savedList); const sidebar = h('div', { class: 'sidebar', style: { width: state.sidebarPx + 'px' } }); const rectFor = (axis) => { diff --git a/src/ui/results.js b/src/ui/results.js index ddc12b9..ab22441 100644 --- a/src/ui/results.js +++ b/src/ui/results.js @@ -7,7 +7,7 @@ import { Icon } from './icons.js'; import { formatRows, formatBytes, isNumericType } from '../core/format.js'; import { looksLikeHtml, prettyValue } from '../core/cell.js'; import { sortRows } from '../core/sort.js'; -import { autoChart, schemaKey, chartFieldOptions, chartColors, chartJsConfig, chartCfgValid, normalizeChartCfg, CHART_ROW_CAP } from '../core/chart-data.js'; +import { autoChart, schemaKey, chartFieldOptions, chartColors, chartJsConfig, chartCfgValid, normalizeChartCfg, unzoomChartEvent, CHART_ROW_CAP } from '../core/chart-data.js'; const VIS_CAP = 5000; const MIN_COL = 48; // px floor for a resized column @@ -338,6 +338,23 @@ function chartEmpty(icon, msg) { return h('div', { class: 'chart-empty' }, h('div', { class: 'chip' }, icon), h('div', null, msg)); } +/** + * Make a Chart.js instance hover-correct under the page's CSS `zoom`. Chart.js + * feeds every pointer event through the controller's single `_eventHandler` + * entry point (a late-bound `this._eventHandler` lookup, so overriding the + * instance property intercepts it) *before* it computes hit-testing / in-area — + * so we divide the zoomed pointer coords back to chart space there (see + * `unzoomChartEvent`). `zoomScale(canvas)` reads the live factor each event, so + * it tracks theme/zoom changes and is a no-op (scale 1) when unzoomed. Returns + * the chart. Exported for tests. + */ +export function installChartZoomFix(chart, canvas) { + const onEvent = chart && chart._eventHandler; + if (typeof onEvent !== 'function') return chart; + chart._eventHandler = (e, replay) => onEvent.call(chart, unzoomChartEvent(e, zoomScale(canvas)), replay); + return chart; +} + export function renderChart(app, r) { const tab = app.activeTab(); // Gate on run state BEFORE deriving the config: while a query streams its @@ -382,7 +399,9 @@ export function renderChart(app, r) { // time series would zig-zag) and change which rows the CHART_ROW_CAP keeps, // contradicting the "first N rows" note. It would also sort up to VIS_CAP // rows just to discard all but the first CHART_ROW_CAP. - app.chart = new app.Chart(canvas, chartJsConfig(r.columns, r.rows, cfg, chartColors(app.cssVar))); + app.chart = installChartZoomFix( + new app.Chart(canvas, chartJsConfig(r.columns, r.rows, cfg, chartColors(app.cssVar))), + canvas); return h('div', { class: 'chart-view' }, bar, h('div', { class: 'chart-canvas-wrap' }, canvas)); } diff --git a/src/ui/saved-history.js b/src/ui/saved-history.js index 9083a92..ecf3840 100644 --- a/src/ui/saved-history.js +++ b/src/ui/saved-history.js @@ -1,10 +1,14 @@ -// The bottom sidebar pane: a Saved / History switcher and the two lists. -// Saved items support favorite (star), inline rename (pencil) and delete (trash). +// The bottom sidebar pane: a Saved / History switcher, a search box, and the +// two lists. Saved items support favorite (star), inline rename (pencil) and +// delete (trash). The search filters the active list (name/description/sql for +// Library, sql for History); it re-renders only the list so typing keeps focus. import { h } from './dom.js'; import { Icon } from './icons.js'; import { timeAgo } from '../core/format.js'; -import { sortedSaved, renameSaved, toggleFavorite, deleteSaved, deleteHistory } from '../state.js'; +import { + sortedSaved, filterSaved, filterHistory, renameSaved, toggleFavorite, deleteSaved, deleteHistory, +} from '../state.js'; export function renderSavedHistory(app) { const tabsRow = app.dom.savedTabsRow; @@ -13,21 +17,67 @@ export function renderSavedHistory(app) { const state = app.state; const count = state.savedQueries.length; + // Switching panes clears the search so each tab starts unfiltered. + const switchTo = (panel) => { + state.sidePanel = panel; + state.libraryFilter = ''; + app.savePref('sidePanel', panel); + renderSavedHistory(app); + }; + tabsRow.replaceChildren( h('button', { class: 'side-tab' + (state.sidePanel === 'saved' ? ' active' : ''), - onclick: () => { state.sidePanel = 'saved'; app.savePref('sidePanel', 'saved'); renderSavedHistory(app); }, + onclick: () => switchTo('saved'), }, Icon.star(state.sidePanel === 'saved'), h('span', null, 'Library'), count ? h('span', { class: 'side-count' }, '· ' + count) : null), h('button', { class: 'side-tab' + (state.sidePanel === 'history' ? ' active' : ''), - onclick: () => { state.sidePanel = 'history'; app.savePref('sidePanel', 'history'); renderSavedHistory(app); }, + onclick: () => switchTo('history'), }, Icon.history(), h('span', null, 'History')), ); + renderSearch(app); + renderList(app); +} + +/** Re-render just the active list (called on every keystroke without rebuilding + * the search input, so the caret/focus survive filtering). */ +function renderList(app) { + const list = app.dom.savedList; list.replaceChildren(); - if (state.sidePanel === 'saved') return renderSaved(app, list); - return renderHistory(app, list); + if (app.state.sidePanel === 'saved') renderSaved(app, list); + else renderHistory(app, list); +} + +/** + * Render the search box into `app.dom.savedSearch` (built once per full render; + * a tab with no items shows nothing). Its `input` handler mutates + * `state.libraryFilter` and re-renders only the list, so it stays focused. + */ +function renderSearch(app) { + const box = app.dom.savedSearch; + if (!box) return; + const state = app.state; + const hasItems = state.sidePanel === 'saved' ? state.savedQueries.length > 0 : state.history.length > 0; + box.replaceChildren(); + if (!hasItems) return; + + const input = h('input', { + class: 'sv-search-input', type: 'text', + placeholder: state.sidePanel === 'saved' ? 'Search saved queries…' : 'Search history…', + value: state.libraryFilter, + }); + const clear = h('button', { class: 'sv-search-clear', title: 'Clear' }, Icon.close()); + const syncClear = () => { clear.style.display = input.value ? '' : 'none'; }; + const setFilter = (v) => { input.value = v; state.libraryFilter = v; syncClear(); renderList(app); }; + + input.addEventListener('input', () => { state.libraryFilter = input.value; syncClear(); renderList(app); }); + input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); setFilter(''); } }); + clear.addEventListener('click', () => { setFilter(''); input.focus(); }); + syncClear(); + + box.append(h('span', { class: 'sv-search-icon' }, Icon.search()), input, clear); } function renderSaved(app, list) { @@ -35,8 +85,14 @@ function renderSaved(app, list) { if (state.savedQueries.length === 0) { list.appendChild(h('div', { class: 'saved-empty' }, 'No saved queries yet.', h('br'), 'Click ', Icon.bookmark(), ' Save next to Run.')); + return; + } + const items = filterSaved(sortedSaved(state), state.libraryFilter); + if (items.length === 0) { + list.appendChild(h('div', { class: 'saved-empty' }, 'No queries match “' + state.libraryFilter.trim() + '”.')); + return; } - for (const q of sortedSaved(state)) { + for (const q of items) { if (app.editingSavedId === q.id) { list.appendChild(savedEditForm(app, q)); continue; } const star = h('button', { class: 'sv-star' + (q.favorite ? ' on' : ''), title: q.favorite ? 'Unfavorite' : 'Favorite', @@ -110,7 +166,12 @@ function renderHistory(app, list) { list.appendChild(h('div', { class: 'saved-empty' }, 'No history yet.')); return; } - for (const ent of state.history) { + const items = filterHistory(state.history, state.libraryFilter); + if (items.length === 0) { + list.appendChild(h('div', { class: 'saved-empty' }, 'No history matches “' + state.libraryFilter.trim() + '”.')); + return; + } + for (const ent of items) { list.appendChild(h('div', { class: 'history-row', onclick: () => { app.actions.loadIntoNewTab('From history', ent.sql); app.actions.run(); } }, h('button', { class: 'sv-act del', title: 'Delete', diff --git a/tests/helpers/fake-app.js b/tests/helpers/fake-app.js index 79d2dbe..9536e2b 100644 --- a/tests/helpers/fake-app.js +++ b/tests/helpers/fake-app.js @@ -12,6 +12,9 @@ export class FakeChart { this.config = config; this.destroyed = false; } + // Mirrors Chart.js's single pointer-event entry point: results.js wraps this + // to undo the page CSS zoom. Records the (corrected) event for assertions. + _eventHandler(e, replay) { this.lastEvent = e; this.lastReplay = replay; } destroy() { this.destroyed = true; } } @@ -48,6 +51,7 @@ export function makeApp(over = {}) { schemaList: document.createElement('div'), resultsRegion: document.createElement('div'), savedTabsRow: document.createElement('div'), + savedSearch: document.createElement('div'), savedList: document.createElement('div'), saveBtn: document.createElement('button'), }, diff --git a/tests/unit/app.test.js b/tests/unit/app.test.js index b8050d5..38fe606 100644 --- a/tests/unit/app.test.js +++ b/tests/unit/app.test.js @@ -368,7 +368,9 @@ describe('formatQuery', () => { ]); app.activeTab().sql = 'select 1'; await app.actions.formatQuery(); - expect(app.dom.editorTextarea.value).toBe('SELECT\n 1'); + // withStatementBreak appends a newline so the caret lands past the last + // token — otherwise the replace re-opens autocomplete on it (#format bug). + expect(app.dom.editorTextarea.value).toBe('SELECT\n 1\n'); }); it('no-ops on empty SQL', async () => { const { app, e } = appFor([]); diff --git a/tests/unit/chart-data.test.js b/tests/unit/chart-data.test.js index 305ebf6..5423f10 100644 --- a/tests/unit/chart-data.test.js +++ b/tests/unit/chart-data.test.js @@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest'; import { chartStripType, chartRole, autoChart, schemaKey, CHART_TYPES, chartFieldOptions, chartNumFmt, chartLabel, chartPalette, chartColors, buildChartData, chartJsConfig, - cloneChartCfg, chartCfgValid, normalizeChartCfg, + cloneChartCfg, chartCfgValid, normalizeChartCfg, unzoomChartEvent, } from '../../src/core/chart-data.js'; describe('chartStripType', () => { @@ -346,3 +346,28 @@ describe('chartCfgValid', () => { expect(chartCfgValid({ type: 'bar', x: 0, y: [0], series: null })).toBe(false); }); }); + +describe('unzoomChartEvent', () => { + it('divides zoomed pointer coords back to chart space under CSS zoom', () => { + const e = { x: 120, y: 60, offsetX: 120, offsetY: 60 }; + expect(unzoomChartEvent(e, 1.2)).toBe(e); // mutates and returns the same object + expect(e.x).toBeCloseTo(100); + expect(e.y).toBeCloseTo(50); + expect(e.offsetX).toBeCloseTo(100); // offsetX/Y kept in sync with x/y + expect(e.offsetY).toBeCloseTo(50); + }); + it('is a no-op at scale 1 (unzoomed) or a missing/zero scale', () => { + const e = { x: 120, y: 60 }; + expect(unzoomChartEvent(e, 1).x).toBe(120); + expect(unzoomChartEvent(e, 0).x).toBe(120); // 0 → falsy → untouched + expect(unzoomChartEvent(e, undefined).x).toBe(120); + }); + it('ignores events without numeric coordinates (resize/attach, null)', () => { + expect(unzoomChartEvent(null, 1.2)).toBeNull(); + const e = { type: 'resize' }; + expect(unzoomChartEvent(e, 1.2)).toBe(e); + expect(e.x).toBeUndefined(); + const partial = { x: 10 }; // y missing → not a pointer position + expect(unzoomChartEvent(partial, 1.2).x).toBe(10); + }); +}); diff --git a/tests/unit/format.test.js b/tests/unit/format.test.js index 44730e8..cd6ad4e 100644 --- a/tests/unit/format.test.js +++ b/tests/unit/format.test.js @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, + clamp, formatRows, formatBytes, timeAgo, sqlString, inferQueryName, isNumericType, shortVersion, userShortName, withStatementBreak, } from '../../src/core/format.js'; describe('clamp', () => { @@ -69,6 +69,23 @@ describe('sqlString', () => { }); }); +describe('withStatementBreak', () => { + it('appends a newline so the caret clears the last token', () => { + expect(withStatementBreak('SELECT 1')).toBe('SELECT 1\n'); + expect(withStatementBreak('SELECT a\nFROM t')).toBe('SELECT a\nFROM t\n'); + }); + it('leaves text already ending in whitespace or a semicolon untouched', () => { + expect(withStatementBreak('SELECT 1\n')).toBe('SELECT 1\n'); + expect(withStatementBreak('SELECT 1 ')).toBe('SELECT 1 '); + expect(withStatementBreak('SELECT 1;')).toBe('SELECT 1;'); + }); + it('coerces nullish/empty to empty string (no stray newline)', () => { + expect(withStatementBreak('')).toBe(''); + expect(withStatementBreak(null)).toBe(''); + expect(withStatementBreak(undefined)).toBe(''); + }); +}); + describe('inferQueryName', () => { it('uses FROM table when present', () => { expect(inferQueryName('SELECT * FROM system.tables')).toBe('Query · system.tables'); diff --git a/tests/unit/results.test.js b/tests/unit/results.test.js index 6203f0f..58083b0 100644 --- a/tests/unit/results.test.js +++ b/tests/unit/results.test.js @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { renderResults, renderJson, renderTable, renderChart, colResizeWidth, openCellDetail } from '../../src/ui/results.js'; +import { renderResults, renderJson, renderTable, renderChart, colResizeWidth, openCellDetail, installChartZoomFix } from '../../src/ui/results.js'; import { makeApp } from '../helpers/fake-app.js'; import { newResult } from '../../src/core/stream.js'; import { schemaKey } from '../../src/core/chart-data.js'; @@ -464,3 +464,23 @@ describe('renderChart', () => { expect(app.activeTab().chartCfg.series).toBeNull(); }); }); + +describe('installChartZoomFix', () => { + it('undoes the page CSS zoom on pointer events before Chart.js hit-tests them', () => { + const app = appWithResult(tableResult(), { resultView: 'chart' }); + renderResults(app); + const canvas = app.chart.canvas; + // Simulate html{zoom:1.2}: rect (zoomed) is 1.2× the layout offsetWidth. + canvas.getBoundingClientRect = () => ({ width: 120, height: 60, left: 0, top: 0, right: 120, bottom: 60 }); + Object.defineProperty(canvas, 'offsetWidth', { value: 100, configurable: true }); + app.chart._eventHandler({ x: 120, y: 60 }, false); // right edge in zoomed px + expect(app.chart.lastEvent.x).toBeCloseTo(100); // mapped back into 0..100 chart space + expect(app.chart.lastEvent.y).toBeCloseTo(50); + expect(app.chart.lastReplay).toBe(false); + }); + it('returns the instance untouched when it has no event handler (or is nullish)', () => { + const chart = { config: {} }; // no _eventHandler + expect(installChartZoomFix(chart, document.createElement('canvas'))).toBe(chart); + expect(installChartZoomFix(null, null)).toBeNull(); + }); +}); diff --git a/tests/unit/saved-history.test.js b/tests/unit/saved-history.test.js index 9394d28..f9f3517 100644 --- a/tests/unit/saved-history.test.js +++ b/tests/unit/saved-history.test.js @@ -198,3 +198,94 @@ describe('renderSavedHistory', () => { expect(app.savePref).toHaveBeenCalledWith('sidePanel', 'saved'); }); }); + +describe('renderSavedHistory — search/filter', () => { + const savedApp = () => { + const app = makeApp(); + app.state.sidePanel = 'saved'; + app.state.savedQueries = [ + { id: 's1', name: 'Carrier delays', sql: 'SELECT carrier FROM flights', favorite: false, description: 'worst delays' }, + { id: 's2', name: 'Busiest airports', sql: 'SELECT origin, count() FROM flights', favorite: false }, + { id: 's3', name: 'Monthly cancellations', sql: 'SELECT month, sum(cancelled)', favorite: false }, + ]; + renderSavedHistory(app); + return app; + }; + const input = (app) => app.dom.savedSearch.querySelector('.sv-search-input'); + const names = (app) => [...app.dom.savedList.querySelectorAll('.saved-row .name')].map((n) => n.textContent); + const type = (app, v) => { const i = input(app); i.value = v; i.dispatchEvent(new Event('input', { bubbles: true })); }; + + it('tolerates a missing search mount', () => { + const app = savedApp(); + app.dom.savedSearch = null; + expect(() => renderSavedHistory(app)).not.toThrow(); + }); + + it('collapses the search box when the active list is empty', () => { + const app = makeApp(); + app.state.sidePanel = 'saved'; + renderSavedHistory(app); + expect(app.dom.savedSearch.children.length).toBe(0); // :empty → hidden via CSS + expect(input(app)).toBeNull(); + }); + + it('shows the box with a per-tab placeholder when items exist', () => { + const app = savedApp(); + expect(input(app).placeholder).toBe('Search saved queries…'); + app.state.sidePanel = 'history'; + app.state.history = [{ id: 'h1', sql: 'SELECT 1', ts: Date.now(), rows: 1, ms: 1 }]; + renderSavedHistory(app); + expect(input(app).placeholder).toBe('Search history…'); + }); + + it('filters saved by name / description / sql, case-insensitively, reusing the input node', () => { + const app = savedApp(); + const before = input(app); + type(app, 'delay'); // s1 name "Carrier delays" + description "worst delays" + expect(names(app)).toEqual(['Carrier delays']); + expect(input(app)).toBe(before); // list-only re-render keeps the input (focus-preserving) + type(app, 'origin'); // s2 sql only + expect(names(app)).toEqual(['Busiest airports']); + type(app, 'CARRIER'); // case-insensitive + expect(names(app)).toEqual(['Carrier delays']); + }); + + it('shows a no-match message and clears via the × button and Escape', () => { + const app = savedApp(); + type(app, 'zzzz'); + expect(app.dom.savedList.textContent).toContain('No queries match'); + expect(app.dom.savedList.textContent).toContain('zzzz'); + click(app.dom.savedSearch.querySelector('.sv-search-clear')); + expect(app.state.libraryFilter).toBe(''); + expect(names(app)).toHaveLength(3); + type(app, 'busiest'); + expect(names(app)).toEqual(['Busiest airports']); + input(app).dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(app.state.libraryFilter).toBe(''); + expect(names(app)).toHaveLength(3); + }); + + it('filters history by sql with its own no-match message', () => { + const app = makeApp(); + app.state.sidePanel = 'history'; + app.state.history = [ + { id: 'h1', sql: 'SELECT 1', ts: Date.now(), rows: 1, ms: 1 }, + { id: 'h2', sql: 'INSERT INTO t', ts: Date.now(), rows: null, ms: 1 }, + ]; + renderSavedHistory(app); + const i = app.dom.savedSearch.querySelector('.sv-search-input'); + i.value = 'insert'; i.dispatchEvent(new Event('input', { bubbles: true })); + expect(app.dom.savedList.querySelectorAll('.history-row')).toHaveLength(1); + expect(app.dom.savedList.textContent).toContain('INSERT INTO t'); + i.value = 'nope'; i.dispatchEvent(new Event('input', { bubbles: true })); + expect(app.dom.savedList.textContent).toContain('No history matches'); + }); + + it('clears the filter when switching tabs', () => { + const app = savedApp(); + type(app, 'delay'); + expect(app.state.libraryFilter).toBe('delay'); + click(app.dom.savedTabsRow.querySelectorAll('.side-tab')[1]); // → History + expect(app.state.libraryFilter).toBe(''); + }); +}); diff --git a/tests/unit/state.test.js b/tests/unit/state.test.js index 200afd0..b5e04fe 100644 --- a/tests/unit/state.test.js +++ b/tests/unit/state.test.js @@ -1,7 +1,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest'; import { KEYS, DEFAULT_LIBRARY_NAME, newTabObj, createState, activeTab, allocTabId, - saveQuery, savedForTab, renameSaved, toggleFavorite, sortedSaved, importSaved, + saveQuery, savedForTab, renameSaved, toggleFavorite, sortedSaved, filterSaved, filterHistory, importSaved, deleteSaved, recordHistory, clearHistory, deleteHistory, tabChart, renameLibrary, newLibrary, replaceLibrary, appendLibrary, markLibrarySaved, } from '../../src/state.js'; @@ -178,6 +178,29 @@ describe('saved queries', () => { expect(sortedSaved(s).map((q) => q.id)).toEqual(['c', 'a', 'b']); expect(save).toHaveBeenCalledTimes(1); }); + it('filterSaved matches name/description/sql case-insensitively; blank → unchanged', () => { + const list = [ + { id: 'a', name: 'Carrier delays', sql: 'SELECT carrier', description: 'worst delays' }, + { id: 'b', name: 'Airports', sql: 'SELECT origin FROM flights' }, + { id: 'c', name: 'Cancellations', sql: 'SELECT month' }, + ]; + expect(filterSaved(list, '').map((q) => q.id)).toEqual(['a', 'b', 'c']); + expect(filterSaved(list, ' ')).toBe(list); // blank → same reference, no copy + expect(filterSaved(list, 'CARRIER').map((q) => q.id)).toEqual(['a']); // name + sql + expect(filterSaved(list, 'delays').map((q) => q.id)).toEqual(['a']); // description + expect(filterSaved(list, 'origin').map((q) => q.id)).toEqual(['b']); // sql + expect(filterSaved(list, 'zzz')).toEqual([]); + }); + it('filterSaved tolerates entries missing fields', () => { + const list = [{ id: 'x' }, { id: 'y', name: 'Yo' }]; + expect(filterSaved(list, 'yo').map((q) => q.id)).toEqual(['y']); + }); + it('filterHistory matches sql case-insensitively; blank → unchanged', () => { + const list = [{ id: 'h1', sql: 'SELECT 1' }, { id: 'h2', sql: 'INSERT INTO t' }, { id: 'h3' }]; + expect(filterHistory(list, '')).toBe(list); + expect(filterHistory(list, 'insert').map((h) => h.id)).toEqual(['h2']); + expect(filterHistory(list, 'zzz')).toEqual([]); + }); it('importSaved merges (add/skip/update), persists, and uses injected genId', () => { const s = createState(reader()); s.savedQueries = [{ id: 's1', name: 'A', sql: '1', favorite: false }];