janela 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +26 -8
  4. data/UPGRADING.md +59 -0
  5. data/app/assets/javascripts/janela/chart_controller.js +148 -42
  6. data/app/assets/stylesheets/janela.css +7 -0
  7. data/app/controllers/janela/panes_controller.rb +2 -2
  8. data/app/controllers/janela/queries_controller.rb +12 -0
  9. data/app/controllers/janela/snapshot_queries_controller.rb +2 -0
  10. data/app/helpers/janela/frames_helper.rb +37 -9
  11. data/app/models/janela/pane.rb +33 -2
  12. data/app/models/janela/query.rb +252 -27
  13. data/app/models/janela/snapshot.rb +2 -2
  14. data/app/views/janela/frames/show.html.erb +2 -2
  15. data/app/views/janela/panes/_form.html.erb +53 -0
  16. data/app/views/janela/queries/_query.html.erb +18 -5
  17. data/config/locales/en.yml +26 -1
  18. data/db/migrate/20261013000001_add_sort_to_janela_panes.rb +7 -0
  19. data/db/migrate/20261029000001_add_series_to_janela_panes.rb +9 -0
  20. data/db/migrate/20261029000003_add_ignore_unknown_filters_to_janela_panes.rb +8 -0
  21. data/docs/agents.md +33 -2
  22. data/docs/decisions/018-a-table-is-the-universal-renderer.md +1 -0
  23. data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +1 -0
  24. data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +1 -1
  25. data/docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md +204 -0
  26. data/docs/decisions/056-the-demo-counts-its-own-visits-and-an-agent-arranges-the-dashboard.md +51 -0
  27. data/docs/decisions/057-a-host-is-told-about-the-agent-tools-and-a-generator-sets-them-up.md +197 -0
  28. data/docs/decisions/058-the-deployed-demo-is-reached-over-ssh-and-its-visits-frame-survives-a-reset.md +94 -0
  29. data/docs/decisions/059-a-host-can-tell-janelas-own-pages-that-its-layout-draws-charts.md +91 -0
  30. data/docs/decisions/060-the-demo-tells-search-engines-what-it-is-and-shows-its-own-adoption.md +79 -0
  31. data/docs/decisions/061-the-demo-speaks-brazilian-portuguese-under-a-prefix-and-the-gem-stays-english.md +118 -0
  32. data/docs/decisions/062-a-time-pane-can-accumulate-and-carry-more-series-on-two-axes.md +282 -0
  33. data/docs/decisions/063-a-pane-can-ignore-a-filter-its-model-cannot-apply.md +147 -0
  34. data/docs/decisions/INDEX.md +25 -16
  35. data/docs/roadmap.md +10 -4
  36. data/lib/generators/janela/mcp/mcp_generator.rb +51 -0
  37. data/lib/generators/janela/mcp/templates/janela-mcp.tt +50 -0
  38. data/lib/janela/definition.rb +73 -4
  39. data/lib/janela/dimension.rb +9 -1
  40. data/lib/janela/doctor.rb +10 -1
  41. data/lib/janela/measure.rb +8 -0
  42. data/lib/janela/tools.rb +37 -4
  43. data/lib/janela/version.rb +1 -1
  44. data/lib/janela.rb +12 -0
  45. metadata +22 -8
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69f4c0147424e4d9350bcee373b832e922f54b1686acce5c80fa776432a288c6
4
- data.tar.gz: ec2f91ec4c87dcfeb49154b54e0cda105f293e8b56580fb774750011fb9277df
3
+ metadata.gz: 83aee3a06740005fd9bf9e81ddc12c6de350ea4b8f6fce34899a94869e0f2615
4
+ data.tar.gz: cd6d59364454c17e80302de50377a5ca2ca3f2db2ceb2bd58be8ca6a29d6b878
5
5
  SHA512:
6
- metadata.gz: 38e32300abc75aa9b738d63b2d7f638dff0d9f153e19aa32f1cb5cbd422702c4e236bc93b87e7c4474836bbd3bfddfad4f83fbaa95d523efee8e6b82b6246b12
7
- data.tar.gz: fe82fff29b230ecab68eb8c14e162d214ee9c1666ead178145c73ffe776e635b8acd59fa8cc1df1011a9f091b2d50e9d9fe299e67142657ed59e69fa1d70d120
6
+ metadata.gz: 670dcac643875f4723159a3f8ecc4429d9feb115dcf58c7336fc823f8290eff4bb3c25316893dc382958753d43e689c4572416d9371b7fa36e0e23447640052c
7
+ data.tar.gz: 6c423db2685c60bb12a6f3878ea956fe926dfde507a9a373d71be3f42092322a22a6958ecf6cc54aee68e6535fd8690e9d4320a0a55da2aa6eed4e40074b0734
data/CHANGELOG.md CHANGED
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.16.0] - 2026-10-29
9
+
10
+ ### Added
11
+
12
+ - **A time pane can read as a running total, and carry more series on two axes.** `janela_pane Order, :orders, by: :placed_on, as: :line, granularity: :month, cumulative: true` draws orders to date rather than orders per month, summed after the read so the buckets, the filters and a click on a bucket are unchanged. The same pane takes `series:`, up to three more measures of the same model over the same buckets, each optionally by a different time dimension (`by: :shipped_on`), each a running total or not, each drawn as a line or as bars (`as:`), each on the left axis or a right one (`axis:`), with a `title` for the legend or one Janela writes (`Orders by Shipped on to date`). Every series' buckets are joined into one axis and a series with no row in a bucket reads zero there (a gap, for a measure that does not add up). A chart with series shows a legend, colours by series rather than by category, draws bars behind lines, and when a series is on the right both axes are titled; a right axis holding only bars is scaled so the tallest reaches about a third of the plot. A table draws them as columns, which is the fallback with no chart runtime; a ring ignores them; a snapshot holds none. `cumulative` is refused on a category pane or a single value, and on an average, minimum, maximum or ratio, which do not add up; a set one joins the pane's frame id, and so does a digest of the series. A bucket an average, minimum, maximum or ratio series has no row in is a gap rather than a zero, and series that would span more than 10,000 buckets are refused. Both are a checkbox and plain selects on the pane form, attributes in `Janela::Tools`, and `?cumulative=1` and `series[][measure]=...` in the pane URL, each series led by its measure. Until now the chart a host wanted, sign-ups to date and activations to date as lines with new sign-ups per week as bars, was a partial pane with its own Chart.js that did not cross-filter and was not a row. Stored panes need a migration; see `UPGRADING.md` (ADR 062, #90).
13
+ - **A pane can ignore a filter its model cannot apply.** On a frame holding panes over two models, a click on one side wrote a key the other side's model does not declare, and that pane answered "That request is not allowed on this pane" where its number was. `janela_pane PageView, :views, by: :page, ignore_unknown_filters: true`, the checkbox on the pane form, `ignore_unknown_filters` in `Janela::Tools` or `?ignore_unknown_filters=1` in the pane URL drops the reader's keys Ransack would not apply to the pane's model before the query, so the pane draws as if they were never written. A key the model declares is applied as before, a declared key with a disallowed predicate is still refused, and a host's `where:` and a frame's `default_where` are still refused when wrong. Unset, nothing changes: a key nobody declares is still refused with its reason (ADR 025). Stored panes need a migration; see `UPGRADING.md` (ADR 063, #92).
14
+
15
+ - **A time pane's month label is written in the reader's language.** `Oct 2026` used `strftime`, which does not read a locale, so a host that set one still drew English months. It now uses the locale's abbreviated month names when the host has them (`out 2026` with Rails' Portuguese data, from the `rails-i18n` gem), and falls back to the English it always drew when it has not, so a host that never set a locale sees no change. A clicked label still maps back to its bucket through a lookup, not by reading the text, so a time click is unaffected (ADR 061).
16
+
17
+ - **`bin/rails generate janela:mcp` writes the server an agent reaches the tools through.** It creates `bin/janela-mcp`, a stdio server on the official Ruby SDK, and a `janela` entry in `.mcp.json` so Claude Code and other clients find it. Both are yours once written. The file is the adapter from `docs/agents.md` with the scope left for you to write, and until you write it every tool refuses to read with a message saying so. It refuses to run outside development. The generator leaves an existing server or entry alone, and tells you to add `gem "mcp"` rather than editing your Gemfile. The scope cannot be your controller's `policy_scope`, because outside a request that method answers a different question and reads the wrong rows (ADR 057).
18
+
19
+ - **`janela:doctor` tells a host the agent tools exist.** When `bin/janela-mcp` is missing it ends with one line, `Optional: let an agent arrange dashboards. bin/rails generate janela:mcp (docs/agents.md).` It is not a finding, so it is never counted, cannot be silenced and does not change whether the task passes. It is printed on a clean run too (ADR 057).
20
+
21
+ - **`Janela.charts_on_own_pages`, so Janela's own frame page can draw its charts.** That page showed every bar and line pane as its table, because its minimal layout loads no chart runtime and the engine cannot see whether a host's layout does (ADR 018). A host that has given those pages a layout registering the two Stimulus controllers and loading Chart.js can now say so, once, and the page draws and cross-filters like its own. It defaults to `false`, so a host that sets nothing sees exactly what it did. A host that sets it without the runtime gets an empty hole where the table was. The pane page opened directly is unchanged (ADR 059).
22
+
23
+ - **`update_frame`, a write tool that changes a frame's columns and gap.** An agent arranging a dashboard could add and move panes but not widen the grid they sit in. It takes `frame_id`, `columns` and `gap`, finds the frame through your scope like every other tool, and refuses a value Janela does not draw with the frame's own reason. A frame's name, key and owner are still yours to set, and any other attribute is refused. Off unless you build the tools with `write: true` (ADR 057).
24
+
25
+ ### Changed
26
+
27
+ - **`value_labels` on a line chart now writes the line's latest value at its end.** A line ignored the flag (ADR 054); it now draws one label, the last point's formatted value, in the same text colour a bar's labels use. A line that never set the flag is unchanged, and a bar chart's labels are what they were (ADR 062).
28
+
29
+ ## [0.15.0] - 2026-10-13
30
+
31
+ ### Added
32
+
33
+ - **A category pane can be sorted by its measure either way, or by its label.** `janela_pane Order, :revenue, by: :customer, limit: 5, sort: :asc` shows the bottom five, and `janela_pane Order, :revenue, by: :cycle, as: :bar, sort: :label` draws a chart in the dimension's own order. `sort` takes `desc` (the default, and what every pane did before), `asc`, `label` and `label_desc`, as a select on the pane form and in `Janela::Tools`, and travels in the pane URL as `?sort=asc`. The limit follows the order, so `asc` with a limit is the real smallest rows and not the largest turned round, which the view could not do. A label order is the database sorting the column, so a number is in numeric order, a date in date order and a string in the database's collation. The `(none)` group is last in every order, because SQLite and PostgreSQL disagree about where NULL sorts. A time pane is always in time order and ignores it. Stored panes need a migration; see `UPGRADING.md`. `Janela.sorts` lists the four for a page that offers them as controls (ADR 055, #75, #80).
34
+
35
+ ### Changed
36
+
37
+ - **Groups tied on the measure now come out by label.** Two groups with the same value used to come out in whatever order the database produced, which with a `limit` decided which of them was shown at all. They are now in label order, ascending, in either direction. A pane with no ties draws exactly what it did. A set `sort` is part of the pane's frame id (`..._top5_asc`), and an unset one leaves every existing id as it was (ADR 055).
38
+
8
39
  ## [0.14.0] - 2026-10-07
9
40
 
10
41
  ### Added
@@ -289,6 +320,8 @@ First alpha, installed from GitHub for testing in a single host application.
289
320
  - Only models that declare a `janela` block are addressable over HTTP.
290
321
  - ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
291
322
 
323
+ [0.16.0]: https://github.com/retail-tasker/janela/releases/tag/v0.16.0
324
+ [0.15.0]: https://github.com/retail-tasker/janela/releases/tag/v0.15.0
292
325
  [0.14.0]: https://github.com/retail-tasker/janela/releases/tag/v0.14.0
293
326
  [0.13.0]: https://github.com/retail-tasker/janela/releases/tag/v0.13.0
294
327
  [0.12.0]: https://github.com/retail-tasker/janela/releases/tag/v0.12.0
data/README.md CHANGED
@@ -27,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It is a
27
27
 
28
28
  ```ruby
29
29
  # Gemfile
30
- gem "janela", "~> 0.14"
30
+ gem "janela", "~> 0.16"
31
31
  ```
32
32
 
33
33
  ```ruby
@@ -42,7 +42,7 @@ Janela reads nothing until your `ApplicationController` says what a visitor may
42
42
  private def policy_scope(model) = model.all
43
43
  ```
44
44
 
45
- If you use stored frames or snapshots, run `bin/rails janela:install:migrations && bin/rails db:migrate` (see Frames below), and finish with `bin/rails janela:doctor`, which checks the install and says what to fix. The rest of this section is the JavaScript.
45
+ If you use stored frames or snapshots, run `bin/rails janela:install:migrations && bin/rails db:migrate` (see Frames below), and finish with `bin/rails janela:doctor`, which checks the install and says what to fix. If you want an agent to arrange dashboards for you, `bin/rails generate janela:mcp` sets that up (see Giving an agent access below). The rest of this section is the JavaScript.
46
46
 
47
47
  Then register the two Stimulus controllers. How depends on how your app ships JavaScript.
48
48
 
@@ -163,7 +163,7 @@ measure :pass_rate, average: :score, precision: 1, suffix: "%" # 66.7%
163
163
  measure :orders, count: true # 1,234
164
164
  ```
165
165
 
166
- Precision defaults to what the schema already says. Counting rows has no decimal places, a `decimal(10, 2)` column reads to the cent, and summing an integer column stays whole. Declare `precision:` where the schema has nothing to say, such as averaging an integer, or where you want something else. Thousands are delimited with your app's locale.
166
+ Precision defaults to what the schema already says. Counting rows has no decimal places, a `decimal(10, 2)` column reads to the cent, and summing an integer column stays whole. Declare `precision:` where the schema has nothing to say, such as averaging an integer, or where you want something else. Thousands are delimited with your app's locale, and a month on a time pane is written with its month names.
167
167
 
168
168
  Formatting is rendering, never rounding. The number itself reaches a snapshot and an order clause at full precision, so a snapshot taken last month reads back under a format you declare today.
169
169
 
@@ -224,7 +224,7 @@ Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one r
224
224
  <% end %>
225
225
  ```
226
226
 
227
- A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars. `as:` is `:table` by default, `:bar` for a Chart.js bar chart, `:doughnut` or `:pie` for a part-to-whole split, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
227
+ A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars, and `sort:` changes which ten (see below). `as:` is `:table` by default, `:bar` for a Chart.js bar chart, `:doughnut` or `:pie` for a part-to-whole split, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
228
228
 
229
229
  A doughnut or a pie is drawn on the server as SVG with a legend of buttons beside it, so it is in the page before any JavaScript runs and can be operated from the keyboard through the legend. Bars, doughnut slices and pie slices are drawn in a palette of eight colours by position, first to eighth, and every value after the eighth in one neutral, so a ring suits a handful of values: `limit: 8` keeps it readable. A ring cannot show a negative value, so a pane with one is drawn as a table and says so. The palette is `--janela-series-1` to `--janela-series-8` and `--janela-series-other` in [docs/theming.md](docs/theming.md) (ADR 046).
230
230
 
@@ -232,10 +232,24 @@ A doughnut or a pie is drawn on the server as SVG with a legend of buttons besid
232
232
 
233
233
  **Prominence.** A single value is `2rem` unless the pane says how much it matters: `prominence: 1` is a footnote at `1.25rem`, `2` is what it already is, and `3` is the hero number at `3.5rem` (`janela_pane Order, :orders, prominence: 3`). Three steps rather than a length, the same as `span` and `height`, so a stored pane takes it too, from the pane form. The label stays small at every step. With none set nothing changes, and a table, a chart and a ring ignore one (ADR 050).
234
234
 
235
- **Value labels.** A bar chart shows a value only in its tooltip unless the pane asks for it on the bar: `value_labels: true` (`janela_pane Order, :expedited_rate, by: :channel, as: :bar, value_labels: true`), or the checkbox on the pane form. The label is the same string the tooltip shows, the measure's own format, drawn beyond the end of the bar: above a positive one and below a negative. It is all or none: if any label is wider than its bar's share of the chart, none is drawn, rather than some bars carrying a number and others not. Make the chart wider or `limit:` the bars. With none set nothing changes, and a line, a ring, a table and a single value ignore it (ADR 054).
235
+ **Sorting.** A breakdown by a category is largest first by default. `sort: :asc` puts the smallest first, so `limit: 5` is the bottom five, and `sort: :label` and `sort: :label_desc` order by the dimension's own value, which suits a label whose text is already the natural order, such as a period code (`janela_pane Order, :revenue, by: :cycle, as: :bar, sort: :label`). The limit follows the order. A number or a date column sorts as itself, and text sorts by your database's collation, which is not the same everywhere. The `(none)` group is always last. Groups that tie on the measure come out by label. It is a select on the pane form, `sort:` in `Janela::Tools`, and `?sort=` in the pane URL. A time series is always in time order and ignores it (ADR 055).
236
+
237
+ **Value labels.** A bar chart shows a value only in its tooltip unless the pane asks for it on the bar: `value_labels: true` (`janela_pane Order, :expedited_rate, by: :channel, as: :bar, value_labels: true`), or the checkbox on the pane form. The label is the same string the tooltip shows, the measure's own format, drawn beyond the end of the bar: above a positive one and below a negative. It is all or none: if any label is wider than its bar's share of the chart, none is drawn, rather than some bars carrying a number and others not. Make the chart wider or `limit:` the bars. On a line it writes the line's latest value, once, at the end of the line, which is what a running total wants (ADR 062). With none set nothing changes, and a ring, a table and a single value ignore it (ADR 054).
236
238
 
237
239
  **Companion columns.** A table can carry up to three more columns beside its label, each a measure or a dimension the model declares: `janela_pane Order, :expedited_rate, by: :customer, companions: [:orders, :region]` reads as customer, expedited rate, orders and region, under a header row. A measure companion is its own number, formatted as it declares, so a rate can sit beside the count behind it: 75% of four and 75% of forty are different things to act on. A dimension companion shows a value only where every row of that label's group shares one, and is blank where they differ, since grouping by it would show a label twice and picking one would show an arbitrary value as fact. Order, limit, filters and clicking a label are the primary measure's and are unchanged. Only a table draws companions, a stored snapshot shows the base table, and a time pane may carry measures but not dimensions. Each companion is one more query, which is why it is three at most (ADR 051).
238
240
 
241
+ **Running totals and more series.** A time pane can read as a total to date rather than a figure per period: `cumulative: true` (`janela_pane Order, :orders, by: :placed_on, as: :line, granularity: :month, cumulative: true`), or the checkbox on the pane form. The running total is summed after the read, so the buckets, the filters and a click on a bucket are what they were, and it is refused on a category pane or a single value, or on a measure that does not add up (an average, minimum, maximum or ratio), rather than drawing something else. A set `cumulative` is part of the pane's frame id, since a measure to date and per period are two different answers that may share a page. The same pane can carry up to three more series over the same buckets, each a measure the model declares, optionally over a different time dimension of the same model, each drawn as a line or as bars, on the left axis or a right one:
242
+
243
+ ```erb
244
+ <%= janela_pane Order, :orders, by: :placed_on, as: :line, granularity: :month, cumulative: true,
245
+ series: [
246
+ { measure: :orders, by: :shipped_on, cumulative: true, title: "Orders shipped to date" },
247
+ { measure: :orders, as: :bar, axis: :right, title: "Orders per month" }
248
+ ] %>
249
+ ```
250
+
251
+ A series takes `measure`, `by` (a time dimension; the pane's own when left out), `cumulative`, `as` (`line` or `bar`; the pane's renderer when it is one of those, else `line`), `axis` (`left` or `right`) and `title`. Every series' buckets are joined into one axis, from the first bucket any of them has to the last, and a series with no row in a bucket reads zero there (a gap for an average, minimum, maximum or ratio, which have no value in an empty bucket), so `Orders shipped to date` is flat across a month nothing shipped. A series by another time dimension counts rows where that column falls in the bucket, so a null is not counted anywhere. The title Janela writes when you leave it out is the measure's name, then `by` and the dimension's when it is not the pane's, then `to date` when it accumulates. A chart with series shows a legend and colours by series, `--janela-series-1` for the pane's own and the rest in order, bars behind lines; a right axis exists only when a series asks for it, and then both axes are titled with the series on them, and a right axis that holds only bars is scaled so the tallest reaches about a third of the plot. A table draws the series as columns after the measure's, which is also what a surface with no chart runtime shows; a ring ignores them and a stored snapshot holds none. On a chart with series, `value_labels` writes each line's latest value at the end of the line and labels no bar. Filters narrow every series the same way, and a click on the chart writes the pane's own dimension's range. It is rows on the pane form, `cumulative:` and `series:` in `Janela::Tools`, and `?cumulative=1` and `series[][measure]=orders&series[][by]=shipped_on` in the pane URL, each series led by its measure (ADR 062).
252
+
239
253
  **Reconfiguring a pane in place**, a renderer toggle, a granularity switcher, a "show top 20" control, takes two things: name the pane with `id:`, then ask the frame to repoint it.
240
254
 
241
255
  ```erb
@@ -333,7 +347,7 @@ The engine serves an index and a page per frame at the mount root, so you can in
333
347
 
334
348
  No model's route key is all digits, so a frame id and a pane URL cannot be confused. Both pages read through `policy_scope(Janela::Frame)`, so a frame your scope does not return is a 404 rather than a page, and so is a pane row under it.
335
349
 
336
- These pages render in Janela's own minimal layout, which loads the gem's stylesheet and nothing else. It does not load Turbo or Stimulus, because those come from your bundler and the engine cannot name them. So Janela's own pages are correct, styled, **static** dashboards: every pane is rendered inline and the numbers are right, filters in the URL apply, and nothing cross-filters when you click. A chart pane needs Chart.js, so on these pages it renders as its table (ADR 018); put a frame on your own page, where your JavaScript is, for the interactive version. Each page links back to your application's root, which you can rename in your own locale file under `janela.actions.home`.
350
+ These pages render in Janela's own minimal layout, which loads the gem's stylesheet and nothing else. It does not load Turbo or Stimulus, because those come from your bundler and the engine cannot name them. So Janela's own pages are correct, styled, **static** dashboards: every pane is rendered inline and the numbers are right, filters in the URL apply, and nothing cross-filters when you click. A chart pane needs Chart.js, so on these pages it renders as its table (ADR 018); put a frame on your own page, where your JavaScript is, for the interactive version. If you have given these pages a layout of your own that registers the two Stimulus controllers and loads Chart.js, say so with `Janela.charts_on_own_pages = true` in `config/initializers/janela.rb`, and the frame page draws its charts and cross-filters too (ADR 059). Leave it off otherwise: the engine cannot see what your layout loads, and a chart with no runtime is an empty hole. Each page links back to your application's root, which you can rename in your own locale file under `janela.actions.home`.
337
351
 
338
352
  The noun in the headings is `Janela::Frame.model_name.human`, so rename it in your own locale file rather than in a setting:
339
353
 
@@ -376,6 +390,8 @@ A host with no tenancy defines nothing, gets a nil owner, and is correct: nothin
376
390
 
377
391
  The dashboard's filters live in the page URL as the same `q[...]` parameters, so a reload keeps them and a filtered dashboard is a link you can send: `/reports/orders?q[status_in][]=paid` renders filtered before any JavaScript runs. A pane ignores filters on its own dimension, so clicking a value re-scopes the rest of the dashboard rather than collapsing the pane you clicked. Clicking a bucket on a time pane filters the others to the range it covers. Every selected value is marked `aria-pressed="true"` on tables and drawn solid against faded siblings on charts, so it can be styled and read.
378
392
 
393
+ **A frame over two models.** Every pane is handed the frame's filters, and a pane refuses a key its model does not declare, because Ransack would otherwise drop it and show an unfiltered number (ADR 025). On a frame that holds panes over two models that refusal reaches a click it should not: `paid` clicked on an orders pane is `q[status_in][]=paid` to a page views pane, which has no status and would answer "That request is not allowed on this pane" where its number was. A pane can say it ignores what it cannot apply: `janela_pane PageView, :views, by: :page, ignore_unknown_filters: true`, or the checkbox on the pane form, or `?ignore_unknown_filters=1` in the pane URL. The keys Ransack would drop are dropped before the query, a key the model does declare is applied as on any pane, a declared key with a predicate the dimension does not allow is still refused, and a host's `where:` and a frame's `default_where` are still refused when wrong. Unset, a pane is what it always was, so a typo on a pane that never meets another model still fails loudly. Two models share a click only when they spell the key the same, which for a dimension reached through an association is `<association>_<column>` (ADR 063).
394
+
379
395
  **Selecting more than one.** Ctrl or Cmd click adds a value to the selection and takes it out again, leaving the rest alone, which is how every list in every operating system already behaves. A plain click selects one value and replaces whatever was selected, or clears the dimension if that value was the only one. It works the same on a chart. The (none) group, the rows that have nothing there, is a member of the selection like any value: Ctrl or Cmd click it beside a value and the panes show the rows that have that value or nothing (`q[channel_in][]=web&q[channel_null]=1`), where `not_in` and `not_null` together still mean neither (ADR 049). All of it works from the keyboard too: a value is a real `<button>`, so Enter is a click and Ctrl or Cmd with Enter adds. `Escape` clears the frame's filters, and those are the only two keys Janela binds, both only while focus is inside the frame, because a single letter belongs to your application and to any text field on the page (ADR 024). A pane with no matching rows renders a `.janela-empty` paragraph. A group whose dimension is null is labelled `(none)` and filters with Ransack's null predicate rather than an empty string. Only models that declare a `janela` block can be requested over HTTP.
380
396
 
381
397
  ### Pane URLs
@@ -390,6 +406,8 @@ Every pane has its own URL under the mount, and a Turbo Frame in a dashboard loa
390
406
  orders revenue by region where status is paid
391
407
  /dashboards/orders/revenue/placed_on?granularity=week&as=line
392
408
  orders revenue by placed_on, per week, as a line
409
+ /dashboards/orders/orders/placed_on?cumulative=1&series[][measure]=orders&series[][by]=shipped_on&series[][cumulative]=1
410
+ orders to date by placed_on, with orders by shipped_on to date beside it
393
411
  ```
394
412
 
395
413
  The model is its route key (`orders`, `sales_orders`), then the measure, then optionally the dimension. Where an analyst would say *by*, the URL has a `/`; *where* is a `q` filter; *as a bar chart* is `?as=bar`; *top ten* is `?limit=10`; *as of* a snapshot is `/snapshots/:id/` in front. Category panes are always ordered by the measure, largest first; time panes are chronological. A pane opened on its own renders with its filters applied, so a filtered pane is a link you can send someone. ADR 005 has the grammar, ADR 011 the layout it renders in.
@@ -510,7 +528,7 @@ Janela::Tools.all # name, description, input_schema, read_only
510
528
  tools.call("read_pane", pane_id: 4)
511
529
  ```
512
530
 
513
- `scope:` is required and is the same answer your `policy_scope` gives, so an agent reads and changes only what the person it acts for can. The read tools list the vocabulary and frames and read a pane's values. The write tools add, change, remove and move panes, and are off unless asked for. Janela serves no MCP endpoint, edits no configuration and depends on no MCP library; [docs/agents.md](docs/agents.md) shows the few lines that register them with the official Ruby SDK.
531
+ `scope:` is required and is the same answer your `policy_scope` gives, so an agent reads and changes only what the person it acts for can. The read tools list the vocabulary and frames and read a pane's values. The write tools add, change, remove and move panes and change a frame's columns and gap, and are off unless asked for. `bin/rails generate janela:mcp` writes the server file and the `.mcp.json` entry so an agent can reach them, and you write the scope in it. Janela serves no MCP endpoint, edits nothing it did not create and depends on no MCP library; [docs/agents.md](docs/agents.md) has the rest.
514
532
 
515
533
  ### Checking an installation
516
534
 
@@ -563,7 +581,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
563
581
 
564
582
  ## Status
565
583
 
566
- **v0.14.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar, line, doughnut and pie charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames found by owner and key, panes that hold words or a host partial as well as a query, a host-fixed filter no click can remove and a frame's own permanent one beside it, STI subclasses, the engine's own pages for reading and editing them, bar charts that read in their measure's format and can show each bar's value, tools an agent can use to read and arrange a dashboard, and the optional vitral theme work and are covered by unit and real-browser tests, with the classes a theme may target documented in [Theming Janela](docs/theming.md). Not yet built: a visual editor, and narrowing a time pane's own granularity by clicking one of its buckets (a click on a bucket does filter the others). [Vista](docs/roadmap.md), the roadmap, says what 1.0 means and which of these are in it; open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
584
+ **v0.16.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar, line, doughnut and pie charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames found by owner and key, panes that hold words or a host partial as well as a query, a host-fixed filter no click can remove and a frame's own permanent one beside it, STI subclasses, the engine's own pages for reading and editing them, bar charts that read in their measure's format and can show each bar's value, category panes that can be ordered either way by their measure or by their label, tools an agent can use to read and arrange a dashboard, and the optional vitral theme work and are covered by unit and real-browser tests, with the classes a theme may target documented in [Theming Janela](docs/theming.md). Not yet built: a visual editor, and narrowing a time pane's own granularity by clicking one of its buckets (a click on a bucket does filter the others). [Vista](docs/roadmap.md), the roadmap, says what 1.0 means and which of these are in it; open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
567
585
 
568
586
  ## Development
569
587
 
data/UPGRADING.md CHANGED
@@ -12,6 +12,65 @@ bin/rails janela:doctor
12
12
 
13
13
  It reads your application and lists what still needs changing.
14
14
 
15
+ ## 0.15.0 to 0.16.0
16
+
17
+ One migration step, if you use stored frames.
18
+
19
+ **Take the pane migration.**
20
+
21
+ A time pane can now be a running total and carry further series (ADR 062),
22
+ and a pane can ignore a filter its model cannot apply (ADR 063).
23
+ `janela_panes` gains a nullable boolean `cumulative`, a nullable JSON
24
+ `series` and a nullable boolean `ignore_unknown_filters`:
25
+
26
+ ```bash
27
+ bin/rails janela:install:migrations
28
+ bin/rails db:migrate
29
+ ```
30
+
31
+ Every existing pane keeps all three nil and is drawn exactly as before. If you
32
+ never use stored frames, there is nothing to do. `bin/rails janela:doctor`
33
+ names the columns if a stored-frames host skips the step.
34
+
35
+ **A line chart with `value_labels` gains one label.** Nothing to change: a
36
+ line that asked for value labels and got none now has its latest value
37
+ written at its end. A host that screenshots such a chart will see the new
38
+ text.
39
+
40
+ **If you built a combined chart as a partial pane,** with its own Chart.js
41
+ controller, it can become a query pane: `cumulative: true` and `series:` on
42
+ `janela_pane`, or the same on the pane form. The partial keeps working as it
43
+ is; it just never cross-filtered.
44
+
45
+ **A pane that ignores filters still refuses a misused real key.** Nothing to
46
+ change: `ignore_unknown_filters: true` drops only keys the pane's model does
47
+ not declare.
48
+
49
+ ## 0.14.0 to 0.15.0
50
+
51
+ One migration step, if you use stored frames.
52
+
53
+ **Take the pane migration.**
54
+
55
+ A category pane can now be sorted (ADR 055). `janela_panes` gains a nullable
56
+ string `sort`:
57
+
58
+ ```bash
59
+ bin/rails janela:install:migrations
60
+ bin/rails db:migrate
61
+ ```
62
+
63
+ Every existing pane keeps it nil and is ordered exactly as before, largest
64
+ first. If you never use stored frames, there is nothing to do.
65
+ `bin/rails janela:doctor` names the column if a stored-frames host skips the
66
+ step.
67
+
68
+ **If you carry a patch that orders panes,** it can go: `sort: :asc` and
69
+ `sort: :label` are the options it was written to add. Groups that tie on the
70
+ measure now come out in label order where they used to come out in the
71
+ database's, so a pane that was relying on that has nothing to change and is
72
+ now the same every time.
73
+
15
74
  ## 0.13.0 to 0.14.0
16
75
 
17
76
  One migration step, if you use stored frames.
@@ -7,9 +7,15 @@ Chart.register(...registerables)
7
7
  // toggle event a table button emits, so the frame controller cannot tell the
8
8
  // difference. Turbo replaces the turbo frame on every cross-filter, so the
9
9
  // chart is destroyed on disconnect and rebuilt on connect.
10
+ //
11
+ // labels, values and formatted are the pane's own series. A time pane may
12
+ // carry further series, each a dataset of its own on the left axis or a
13
+ // right one, and then the chart has a legend and both axes are titled
14
+ // (ADR 062). Without them nothing here is different from before.
10
15
  export default class extends Controller {
11
16
  static values = { type: String, labels: Array, values: Array, filters: Object, title: String,
12
- selected: Array, formatted: Array, tickFormat: Object, fixedHeight: Boolean, valueLabels: Boolean }
17
+ selected: Array, formatted: Array, tickFormat: Object, fixedHeight: Boolean, valueLabels: Boolean,
18
+ label: String, series: Array, axes: Object }
13
19
 
14
20
  connect() {
15
21
  const controller = this
@@ -20,13 +26,7 @@ export default class extends Controller {
20
26
  plugins: this.valueLabelsValue ? [ { id: "janelaValueLabels", afterDatasetsDraw: (chart) => this.drawValueLabels(chart) } ] : [],
21
27
  data: {
22
28
  labels: this.labelsValue,
23
- datasets: [{
24
- label: this.titleValue,
25
- data: this.valuesValue,
26
- backgroundColor: this.colours(),
27
- borderColor: this.typeValue === "bar" ? this.colours(0.9) : this.accentColour(0.9),
28
- ...this.pointStyle()
29
- }]
29
+ datasets: [ this.primaryDataset(), ...this.seriesValue.map((series, index) => this.seriesDataset(series, index + 1)) ]
30
30
  },
31
31
  options: {
32
32
  animation: false,
@@ -35,24 +35,18 @@ export default class extends Controller {
35
35
  // is decided here, when the chart is made: patched onto a chart built
36
36
  // with it on, it draws at the wrong size.
37
37
  maintainAspectRatio: !this.fixedHeightValue,
38
- // Chart.js picks the ticks, so the server cannot format them; each is
39
- // formatted here with what the measure declares (ADR 054).
40
- scales: { y: {
41
- beginAtZero: true,
42
- // Room above the tallest bar, and below the lowest, for its label (ADR 054).
43
- grace: this.valueLabelsValue ? "10%" : undefined,
44
- ticks: { callback(value, index, ticks) { return controller.tickLabel(this, value, index, ticks) } }
45
- } },
38
+ scales: this.scales(),
46
39
  // A line is clicked anywhere along its x position rather than on
47
40
  // the exact pixel of a point, which on a dense series is a few
48
- // pixels wide (ADR 045).
49
- interaction: this.typeValue === "line" ? { mode: "nearest", axis: "x", intersect: false } : undefined,
41
+ // pixels wide (ADR 045). With several series a bucket is read across
42
+ // all of them at once.
43
+ interaction: this.interaction(),
50
44
  plugins: {
51
- legend: { display: false },
45
+ legend: { display: this.hasSeries() },
52
46
  // The server formatted every number for the table, so the tooltip
53
47
  // reads the same string rather than formatting a second time here
54
48
  // and disagreeing with the cell beside it.
55
- tooltip: { callbacks: { label: (item) => this.formattedValue[item.dataIndex] ?? item.formattedValue } }
49
+ tooltip: { callbacks: { label: (item) => this.formattedFor(item.datasetIndex)[item.dataIndex] ?? item.formattedValue } }
56
50
  },
57
51
  onClick: (event, elements) => {
58
52
  if (elements.length === 0) return
@@ -73,30 +67,133 @@ export default class extends Controller {
73
67
  })
74
68
  }
75
69
 
76
- // The same string the tooltip shows, at the end of each bar: above a positive
77
- // one and below a negative, where the bar is not. All of them or none: a
78
- // label wider than its bar's slot would overlap its neighbour, and hiding only
79
- // the ones that collide would label some bars and not others for a reason a
80
- // reader cannot see (ADR 054).
70
+ hasSeries() {
71
+ return this.seriesValue.length > 0
72
+ }
73
+
74
+ // The pane's own series. Alone it is what it always was: bars coloured by
75
+ // position and a line in the accent. Beside further series it is the first
76
+ // colour of the palette, and the others follow in order (ADR 062).
77
+ primaryDataset() {
78
+ const property = this.hasSeries() ? this.seriesProperty(0) : null
79
+ return {
80
+ label: this.hasSeries() ? this.labelValue : this.titleValue,
81
+ data: this.valuesValue,
82
+ type: this.typeValue,
83
+ yAxisID: "y",
84
+ order: this.typeValue === "bar" ? 2 : 1,
85
+ backgroundColor: this.colours(0.25, property, this.typeValue === "bar" && property ? 0.55 : 0.9),
86
+ borderColor: this.typeValue === "bar" ? this.colours(0.9, property, property ? 0.55 : 0.9) : (property ? this.colour(property, 0.9) : this.accentColour(0.9)),
87
+ ...this.pointStyle(this.typeValue, property)
88
+ }
89
+ }
90
+
91
+ // Bars are drawn behind lines and quieter than them, so a line stays the
92
+ // story over the bars under it.
93
+ seriesDataset(series, position) {
94
+ const property = this.seriesProperty(position)
95
+ return {
96
+ label: series.label,
97
+ data: series.values,
98
+ type: series.type,
99
+ yAxisID: series.axis === "right" ? "y1" : "y",
100
+ order: series.type === "bar" ? 2 : 1,
101
+ backgroundColor: series.type === "bar" ? this.colours(0.2, property, 0.55) : this.colours(0.25, property),
102
+ borderColor: series.type === "bar" ? this.colours(0.2, property, 0.55) : this.colour(property, 0.9),
103
+ ...this.pointStyle(series.type, property)
104
+ }
105
+ }
106
+
107
+ formattedFor(datasetIndex) {
108
+ return datasetIndex === 0 ? this.formattedValue : (this.seriesValue[datasetIndex - 1]?.formatted ?? [])
109
+ }
110
+
111
+ interaction() {
112
+ if (this.hasSeries()) return { mode: "index", intersect: false }
113
+ return this.typeValue === "line" ? { mode: "nearest", axis: "x", intersect: false } : undefined
114
+ }
115
+
116
+ // Chart.js picks the ticks, so the server cannot format them; each is
117
+ // formatted here with what the measure declares (ADR 054). A right axis
118
+ // exists only when a series asks for it, draws no grid across the plot,
119
+ // and both axes are then titled with the series on them. When every series
120
+ // on the right is bars, that axis is scaled so the tallest bar reaches
121
+ // about a third of the plot, so bars sit under the lines (ADR 062).
122
+ scales() {
123
+ const controller = this
124
+ const ticks = { callback(value, index, ticks) { return controller.tickLabel(this, value, index, ticks) } }
125
+ const scales = { y: {
126
+ beginAtZero: true,
127
+ // Room above the tallest bar, and below the lowest, for its label (ADR 054).
128
+ grace: this.valueLabelsValue ? "10%" : undefined,
129
+ title: this.axisTitle(this.axesValue.left),
130
+ ticks
131
+ } }
132
+
133
+ const right = this.seriesValue.filter((series) => series.axis === "right")
134
+ if (right.length > 0) {
135
+ const barsOnly = right.every((series) => series.type === "bar")
136
+ const tallest = Math.max(0, ...right.flatMap((series) => series.values))
137
+ scales.y1 = {
138
+ position: "right",
139
+ beginAtZero: true,
140
+ suggestedMax: barsOnly && tallest > 0 ? tallest * 3 : undefined,
141
+ grid: { drawOnChartArea: false },
142
+ title: this.axisTitle(this.axesValue.right),
143
+ ticks
144
+ }
145
+ }
146
+ return scales
147
+ }
148
+
149
+ axisTitle(text) {
150
+ return text ? { display: true, text } : undefined
151
+ }
152
+
153
+ // With value labels asked for: on a lone bar chart the same string the
154
+ // tooltip shows at the end of each bar, above a positive one and below a
155
+ // negative, all of them or none, since a label wider than its bar's slot
156
+ // would overlap its neighbour and hiding only the ones that collide would
157
+ // label some bars for a reason a reader cannot see (ADR 054). On a line its
158
+ // latest value, once, at the end of the line, which is what a running total
159
+ // wants (ADR 062). Bars beside other series draw none: their slots are
160
+ // shared, and all or none would almost always say none.
81
161
  drawValueLabels(chart) {
82
- const bars = chart.getDatasetMeta(0).data
83
- const values = chart.data.datasets[0].data
84
162
  const { ctx } = chart
85
163
  const { size, family } = Chart.defaults.font
86
- const slot = chart.chartArea.width / bars.length
87
-
88
164
  ctx.save()
89
165
  ctx.font = `${size}px ${family}`
90
- if (this.formattedValue.some((text) => ctx.measureText(text).width > slot)) return ctx.restore()
91
-
92
166
  ctx.fillStyle = Chart.defaults.color
167
+
168
+ chart.data.datasets.forEach((dataset, index) => {
169
+ // A dataset hidden from the legend draws nothing, labels included.
170
+ if (!chart.isDatasetVisible(index)) return
171
+ const formatted = this.formattedFor(index)
172
+ const points = chart.getDatasetMeta(index).data
173
+ if (dataset.type === "line") {
174
+ const last = points.at(-1)
175
+ if (!last || formatted.length === 0) return
176
+ ctx.textAlign = "right"
177
+ ctx.textBaseline = "bottom"
178
+ ctx.fillText(formatted.at(-1), last.x, last.y - 6)
179
+ } else if (!this.hasSeries()) {
180
+ this.drawBarLabels(chart, points, dataset.data, formatted)
181
+ }
182
+ })
183
+ ctx.restore()
184
+ }
185
+
186
+ drawBarLabels(chart, bars, values, formatted) {
187
+ const { ctx } = chart
188
+ const slot = chart.chartArea.width / bars.length
189
+ if (formatted.some((text) => ctx.measureText(text).width > slot)) return
190
+
93
191
  ctx.textAlign = "center"
94
192
  bars.forEach((bar, index) => {
95
193
  const negative = values[index] < 0
96
194
  ctx.textBaseline = negative ? "top" : "bottom"
97
- ctx.fillText(this.formattedValue[index], bar.x, negative ? bar.y + 4 : bar.y - 4)
195
+ ctx.fillText(formatted[index], bar.x, negative ? bar.y + 4 : bar.y - 4)
98
196
  })
99
- ctx.restore()
100
197
  }
101
198
 
102
199
  // Chart.js's own numeric formatter, so the decimals follow the spacing
@@ -105,8 +202,15 @@ export default class extends Controller {
105
202
  // (ADR 038), so its ticks are scaled by 100, rounded first because 0.035 * 100
106
203
  // is 3.5000000000000004. The measure's precision is not used: it says what one
107
204
  // value means, and would print 20.0% at every tick.
205
+ // The right axis reads in the format of the series on it, so it agrees with
206
+ // the tooltip. Where several series share it, the first one speaks.
207
+ tickFormatFor(scale) {
208
+ if (scale.id !== "y1") return this.tickFormatValue
209
+ return this.seriesValue.find((series) => series.axis === "right")?.tick_format ?? this.tickFormatValue
210
+ }
211
+
108
212
  tickLabel(scale, value, index, ticks) {
109
- const { prefix = "", suffix = "", ratio = false } = this.tickFormatValue
213
+ const { prefix = "", suffix = "", ratio = false } = this.tickFormatFor(scale)
110
214
  const factor = ratio ? 100 : 1
111
215
  const scaled = (number) => Number((number * factor).toPrecision(12))
112
216
  const number = Ticks.formatters.numeric.call(scale, scaled(value), index, ticks.map((tick) => ({ ...tick, value: scaled(tick.value) })))
@@ -116,27 +220,29 @@ export default class extends Controller {
116
220
  // With nothing selected every bar is solid; with a selection only the
117
221
  // selected ones are, and there can be more than one of them. A bar takes
118
222
  // the colour for its position in the palette (ADR 046); a line is one
119
- // series and keeps the accent.
120
- colours(unselected = 0.25) {
223
+ // series and keeps the accent. A dataset beside other series is given its
224
+ // own property instead and every bucket takes that one colour, and a bar
225
+ // there is quieter than a line (ADR 062).
226
+ colours(unselected = 0.25, property = null, solidAlpha = 0.9) {
121
227
  const selected = this.selectedValue.map(String)
122
228
  return this.labelsValue.map((label, index) => {
123
229
  const solid = selected.length === 0 || selected.includes(String(label))
124
- const property = this.typeValue === "bar" ? this.seriesProperty(index) : "--janela-accent"
125
- return this.colour(property, solid ? 0.9 : unselected)
230
+ const chosen = property || (this.typeValue === "bar" ? this.seriesProperty(index) : "--janela-accent")
231
+ return this.colour(chosen, solid ? solidAlpha : unselected)
126
232
  })
127
233
  }
128
234
 
129
235
  // A line shows its selection on its points: the buckets inside the range
130
236
  // are solid and larger, the rest faded. A dense series draws no points
131
237
  // until one is hovered, so it stays a line (ADR 045).
132
- pointStyle() {
133
- if (this.typeValue !== "line") return {}
238
+ pointStyle(type = this.typeValue, property = null) {
239
+ if (type !== "line") return {}
134
240
 
135
241
  const selected = this.selectedValue.map(String)
136
242
  const dense = this.labelsValue.length > 60
137
243
  return {
138
- pointBackgroundColor: this.colours(),
139
- pointBorderColor: this.colours(),
244
+ pointBackgroundColor: this.colours(0.25, property),
245
+ pointBorderColor: this.colours(0.25, property),
140
246
  pointRadius: this.labelsValue.map((label) => selected.includes(String(label)) ? 5 : (dense ? 0 : 3)),
141
247
  pointHoverRadius: 6
142
248
  }
@@ -225,6 +225,13 @@ table.janela-legend button[aria-pressed="true"] { background: var(--janela-accen
225
225
  background: transparent;
226
226
  color: inherit;
227
227
  }
228
+ /* One further series per row on the pane form (ADR 062): its measure first,
229
+ then what is left unsaid about it, each a plain control. */
230
+ .janela-series { border: 1px solid var(--janela-line); border-radius: calc(var(--janela-space) * 1.5); padding: calc(var(--janela-space) * 3); }
231
+ .janela-series legend { font-size: 0.875rem; font-weight: 600; padding: 0 calc(var(--janela-space) * 1); }
232
+ .janela-series-slot { display: flex; flex-wrap: wrap; align-items: center; gap: calc(var(--janela-space) * 2); padding-top: calc(var(--janela-space) * 3); }
233
+ .janela-series-slot label { font-weight: 400; }
234
+ .janela-series-slot input[type="text"] { flex: 1 1 10rem; }
228
235
  .janela-actions { display: flex; flex-wrap: wrap; gap: calc(var(--janela-space) * 3); align-items: center; }
229
236
  .janela-actions form { display: inline; }
230
237
  .janela-button {
@@ -76,8 +76,8 @@ module Janela
76
76
  end
77
77
 
78
78
  def pane_params
79
- params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :height, :prominence, :value_labels, :span, :title,
80
- :heading, :body, :link, :partial, { companions: [] } ])
79
+ params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :sort, :height, :prominence, :value_labels, :cumulative, :ignore_unknown_filters, :span, :title,
80
+ :heading, :body, :link, :partial, { companions: [], series: [ Janela::Query::SERIES_KEYS.map(&:to_sym) ] } ])
81
81
  end
82
82
 
83
83
  def build_pane(choice)
@@ -8,15 +8,27 @@ module Janela
8
8
  renderer: params.fetch(:as, "table"),
9
9
  granularity: params[:granularity],
10
10
  limit: params[:limit],
11
+ sort: params[:sort],
11
12
  height: params[:height],
12
13
  prominence: params[:prominence],
13
14
  value_labels: params[:value_labels],
14
15
  companions: params[:companions],
16
+ cumulative: params[:cumulative],
17
+ series: series_params,
18
+ ignore_unknown_filters: params[:ignore_unknown_filters],
15
19
  filters: filters,
16
20
  fixed: fixed_filters
17
21
  )
18
22
 
19
23
  @result = @query.result(on: janela_scope(@query.model))
20
24
  end
25
+
26
+ private
27
+ # A list of hashes, each led by its measure (ADR 062). Anything else in
28
+ # the parameter is handed to the query to be refused with its reason.
29
+ def series_params
30
+ given = params[:series]
31
+ given.is_a?(Array) ? given.map { |each| each.respond_to?(:permit!) ? each.permit!.to_h : each } : given
32
+ end
21
33
  end
22
34
  end
@@ -13,10 +13,12 @@ module Janela
13
13
  renderer: params.fetch(:as, "table"),
14
14
  granularity: params[:granularity],
15
15
  limit: params[:limit],
16
+ sort: params[:sort],
16
17
  height: params[:height],
17
18
  prominence: params[:prominence],
18
19
  value_labels: params[:value_labels],
19
20
  companions: params[:companions],
21
+ cumulative: params[:cumulative],
20
22
  snapshot: snapshot
21
23
  )
22
24