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
@@ -0,0 +1,282 @@
1
+ ---
2
+ Date: 2026-10-29
3
+ Status: Proposed
4
+ Related: ADR 002, ADR 005, ADR 006, ADR 007, ADR 009, ADR 018, ADR 020, ADR 029, ADR 037, ADR 045, ADR 046, ADR 047, ADR 051, ADR 054, ADR 055
5
+ Triggers:
6
+ - a running total, a cumulative line, a to-date figure over time
7
+ - drawing two measures on one chart, or a bar beside a line
8
+ - a second value axis, a right axis, or two scales on one plot
9
+ - a series bucketed by a different date column than the pane's
10
+ - adding a column to janela_panes, or a parameter to the pane URL
11
+ - choosing the colour of a series rather than of a category
12
+ - a chart legend, or a title on an axis
13
+ Topics: charts, time, dsl, query layer, panes, urls, public-api, frames, styling
14
+ ---
15
+
16
+ # ADR 062: A Time Pane Can Accumulate, and Carry More Series on Two Axes
17
+
18
+ ## Context
19
+
20
+ #90, raised from a real install building an adoption chart: sign-ups to
21
+ date and activations to date as two lines on the left axis, new sign-ups
22
+ per week as bars on a right axis, one chart, filtered by the panes around
23
+ it. The host built it as a partial pane (ADR 039) holding its own Chart.js
24
+ controller and its own query. It draws, and it is wrong in three ways that
25
+ matter: it does not change when the reader clicks another pane, it is not a
26
+ row an analyst can edit, and it is a second chart implementation the host
27
+ has to keep in step with the gem's.
28
+
29
+ Four gaps stand between a pane and that chart, measured against `main`
30
+ before deciding.
31
+
32
+ **Nothing accumulates.** `Definition#series` returns one value per bucket
33
+ and `Query#result` hands it on. A chart of orders per month cannot be a
34
+ chart of orders to date without a column on the model that stores the
35
+ running count, which is presentation in the schema.
36
+
37
+ **One measure, one dimension.** `Query` holds exactly one of each. The
38
+ chart partial hands the controller one `labels` and one `values` array,
39
+ and `chart_controller.js` builds one dataset over one `y` scale. Two
40
+ measures over the same weeks are two panes, which cannot share an axis.
41
+
42
+ **The renderer is the pane's.** A pane is bars or a line, never both.
43
+
44
+ **A second value axis does not exist.** A count of five beside a total of
45
+ five hundred is a line on the floor.
46
+
47
+ Three facts shape the answer.
48
+
49
+ **Groupdate's keys differ by column type.** A date column's week buckets
50
+ come back as `Date` (`Mon, 31 Aug 2026`), a timestamp's as `Time` in the
51
+ zone. Two series over two columns can only share an x axis if their keys
52
+ are made the same kind first; `in_time_zone` gives the same instant for
53
+ both, and `Dimension#span` already reads a bucket that way.
54
+
55
+ **A list of hashes survives a URL if each starts with the same key.**
56
+ Rack's nested query parser opens a new hash in `series[][...]` when a key
57
+ repeats and otherwise adds to the last one, so `a[][x]=1&a[][y]=4&a[][x]=3`
58
+ gives the first hash the `y` meant for the second. Rails' `to_query`
59
+ leaves keys in their given order inside a `[]` namespace rather than
60
+ sorting them, measured: `{ series: [{ measure: "m", by: "d" }, { measure:
61
+ "n", as: "bar" }] }` writes `measure` first in each and parses back whole.
62
+ A series led by its measure is unambiguous; one that is not may not be.
63
+
64
+ **Chart.js draws a mixed chart from a type per dataset** and a second
65
+ scale from a second entry in `scales`, so the canvas needs nothing new
66
+ shipped (ADR 004).
67
+
68
+ ### What was considered
69
+
70
+ **A stored column for the running total.** The host's workaround today.
71
+ Rejected: ADR 051 refused presentation in the schema for companions, and
72
+ a running total is the measure's own numbers added up, which is one line
73
+ of Ruby after the read.
74
+
75
+ **A `combo` renderer.** A fifth renderer whose dataset shape is its own.
76
+ Rejected: it would be a renderer that means "several of the other two",
77
+ and a table could not be its fallback without learning the same shape
78
+ anyway (ADR 018). The series are what is new; which of two existing
79
+ renderers each one uses is one word on it.
80
+
81
+ **A second pane that is told to share an axis with the first.** Two
82
+ panes, two queries, one canvas somehow. Rejected: a pane is one turbo
83
+ frame with one URL (ADR 005, ADR 029), and two of them cannot be one
84
+ `<figure>`.
85
+
86
+ **Series on a categorical pane.** Revenue and orders by status as grouped
87
+ bars. Not built, and not refused for ever: a categorical pane colours by
88
+ position in the result (ADR 046), a series needs colour by series, and
89
+ which rule wins is a decision of its own. The one host that needs this
90
+ needs it over time.
91
+
92
+ **A series with its own granularity.** Rejected: the point of a series is
93
+ that it shares the pane's buckets, and a week beside a month is two x
94
+ axes.
95
+
96
+ **Colour by category on a multi-series bar.** ADR 046 draws a bar in the
97
+ colour of its position. Rejected for a pane with series: a bar series over
98
+ thirty weeks in eight colours and a grey, beside a legend that names one
99
+ series, is a wrong picture. A pane with series colours by series.
100
+
101
+ **Formatted labels on every point of a line.** ADR 054 refused this and
102
+ the refusal stands. What a running total wants is its latest value, once,
103
+ at the end of the line.
104
+
105
+ **A per-series `title` left out, naming by measure alone.** Considered,
106
+ because a measure is already named for what it means on the dashboard
107
+ (ADR 002) and a host can declare `measure :sign_ups, count: true` beside
108
+ `measure :activations, count: true` for the same aggregate. Not enough on
109
+ its own: the same measure over the same dimension, once to date and once
110
+ per week, is the first chart anyone draws with this, and two legend
111
+ entries reading `Orders` is a wrong picture. A series may carry a title,
112
+ and the one Janela writes says what it is.
113
+
114
+ ## Decision
115
+
116
+ **A time pane may carry `cumulative`, a running total of its measure
117
+ across its buckets, and `series`, up to three more measures of the same
118
+ model over the same buckets, each drawn as a line or as bars, each on the
119
+ left axis or a right one. Unset is exactly what a pane is today.**
120
+
121
+ ```erb
122
+ <%= janela_pane Order, :orders, by: :placed_on, as: :line, granularity: :month,
123
+ cumulative: true,
124
+ series: [
125
+ { measure: :orders, by: :shipped_on, cumulative: true, title: "Orders shipped to date" },
126
+ { measure: :orders, as: :bar, axis: :right, title: "Orders per month" }
127
+ ] %>
128
+ ```
129
+
130
+ **`cumulative` is a yes or a no, and a time pane's.** The running total
131
+ is summed in Ruby after the read, so the SQL, the buckets, the filters and
132
+ the click on a bucket are what they always were (ADR 045). A stored
133
+ snapshot keeps per-bucket numbers and a cumulative view of it is summed
134
+ the same way on read, so nothing about what is stored changes (ADR 009).
135
+ Only a measure that adds up accumulates, a count or a sum: an average, a
136
+ minimum, a maximum or a ratio is refused with its reason, as is a series
137
+ with `cumulative` over one, because their sum is a number that means
138
+ nothing. On a categorical pane or a single value it is refused with its reason,
139
+ not ignored: a running total over categories sorted by their measure is a
140
+ different chart that nobody has asked for, and a row whose dimension is
141
+ changed to a category is told so on the form rather than quietly drawing
142
+ something else.
143
+
144
+ **A series is a hash of up to six words, and the first is `measure`.**
145
+
146
+ | key | takes | unset means |
147
+ | --- | --- | --- |
148
+ | `measure` | a measure the model declares | required |
149
+ | `by` | a time dimension the model declares | the pane's own dimension |
150
+ | `cumulative` | `1` or `0` | `0` |
151
+ | `as` | `line` or `bar` | the pane's renderer when it is one of those, else `line` |
152
+ | `axis` | `left` or `right` | `left` |
153
+ | `title` | words for the legend and the table header | Janela writes one |
154
+
155
+ Only what the model declared, by the name it declared it under, as
156
+ companions are bounded (ADR 025, ADR 051). A series by a different time
157
+ dimension shares the pane's granularity: its buckets are read with the
158
+ same `week_start`, every series' buckets are joined into one sorted set
159
+ through `in_time_zone`, and a bucket a series has no row in is zero for a count or a sum, so
160
+ `Orders shipped to date` is flat across a month nothing shipped rather
161
+ than absent, and a gap for an average, minimum, maximum or ratio, which
162
+ have no value in an empty bucket. Joined buckets beyond 10,000 are a
163
+ refusal, so a stray year in a column cannot build a hundred thousand. Two series that name the same measure, dimension and
164
+ `cumulative` are refused as one series drawn twice. Four series are a
165
+ refusal that says three.
166
+
167
+ **The title Janela writes** is the measure's name, then `by` and the
168
+ dimension's name when the series is over a dimension other than the
169
+ pane's, then `to date` when it is cumulative: `Orders`, `Orders by
170
+ Shipped on to date`. The same rule names the pane's own series, so a
171
+ cumulative pane's legend and table header read `Orders to date`. A
172
+ `title` replaces all of it and is escaped like any other string.
173
+
174
+ **The pane's own measure is the first series, and it is the left axis.**
175
+ The pane's measure, dimension, renderer and `cumulative` are what they
176
+ were. What a click writes is the pane's dimension's range (ADR 045), and
177
+ the x axis is the pane's buckets. Filters are applied to every series the
178
+ same way: the reader's selection on the pane's own dimension is set aside
179
+ for all of them, because the pane shows the alternatives to it, and a
180
+ selection on any other dimension, including a series' own, narrows that
181
+ series as it narrows every pane.
182
+
183
+ **A bar and a line draw the series as datasets. A table draws them as
184
+ columns. A ring and a single value ignore them,** as a ring ignores a
185
+ height (ADR 047) and every renderer but a table ignores companions (ADR
186
+ 051), so a pane switched between renderers keeps them. The table is the
187
+ fallback on a surface with no chart runtime (ADR 018), with a header row
188
+ as a table with companions has, the series' columns after the measure's
189
+ and before any companions. A stored snapshot holds no series, as it holds
190
+ no companions, so a snapshot pane with them shows the base table.
191
+
192
+ **With series, the chart shows a legend and colours by series.** The
193
+ pane's own series is `--janela-series-1`, the next `--janela-series-2`,
194
+ and so on, in the order given (ADR 046's palette, by series rather than by
195
+ category). Bars are drawn behind lines. A selected bucket is solid and the
196
+ rest faded, in every dataset, as a single series is today. Without series
197
+ nothing about a chart's colour, legend or interaction changes.
198
+
199
+ **A right axis exists when a series asks for it, and then both axes are
200
+ titled.** A right axis draws no grid lines across the plot, so the left
201
+ axis's grid is the only one. Each axis's title is the titles of the
202
+ series on it, in order, because an untitled second scale is the way two
203
+ scales mislead. When every series on the right axis is bars, that axis is
204
+ scaled so the tallest bar reaches about a third of the plot, so bars sit
205
+ under the lines rather than fighting them. A line on the right axis is
206
+ scaled to its own data like any line.
207
+
208
+ **`value_labels` on a line writes its latest value at the end of the
209
+ line, in text ink.** One label, not one per point, which is what ADR 054
210
+ refused. It applies to every line series on a pane that asks for it. Bars
211
+ on a pane with series draw no labels: their slots are shared and the all
212
+ or none rule of ADR 054 would almost always say none. A single-series bar
213
+ chart is unchanged. This supersedes the sentence in ADR 054 that a line
214
+ ignores the flag, and nothing else in it.
215
+
216
+ **One vocabulary in three places,** as height and companions are:
217
+
218
+ - `Janela::Pane#cumulative`, a nullable boolean, and `Janela::Pane#series`,
219
+ a nullable JSON array of hashes, validated on save against the model's
220
+ declared measures and dimensions through the same `Query` that would
221
+ refuse the request. The pane form offers a checkbox and one blank
222
+ series slot beyond those filled, each a group of plain selects since
223
+ the engine's pages have no JavaScript (ADR 018), wherever it offers a
224
+ granularity: on a pane over a time dimension, or one with no dimension
225
+ chosen yet.
226
+ - `janela_pane` and `janela_snapshot_pane` take `cumulative:` and
227
+ `series:`.
228
+ - The pane URL carries `?cumulative=1` and `series[][measure]=orders&
229
+ series[][by]=shipped_on&series[][cumulative]=1`, each series led by its
230
+ measure, because the server draws the pane again from that URL on every
231
+ cross-filter.
232
+
233
+ **`cumulative` and `series` are part of the pane's identity when set.**
234
+ A measure to date and the same measure per period are two answers that
235
+ may share a page, as top five and bottom five are (ADR 055), so a set
236
+ `cumulative` joins `Query.turbo_frame_id` and an unset one leaves every
237
+ existing id alone. Two panes that differ only in their series draw
238
+ different charts, so a set `series` joins it as a short digest of the
239
+ words given, however they were spelled; none leaves the id as it was.
240
+ `cumulative` is read from the flag the way the server reads it, so `"0"`
241
+ is no in the URL and in the id alike.
242
+
243
+ ## Consequences
244
+
245
+ - The adoption chart is a row, or one `janela_pane`, and it cross-filters,
246
+ falls back to a table, and is edited on the pane form like any other.
247
+ - **A migration for stored frames.** `janela_panes` gains a nullable
248
+ boolean `cumulative` and a nullable JSON `series`, additive, in one
249
+ migration. A host using stored frames runs `bin/rails
250
+ janela:install:migrations` and `db:migrate`, written in `UPGRADING.md`.
251
+ The doctor's `unmigrated-columns` check is taught both.
252
+ - **`Janela::Tools` learns both attributes** in the same change, with the
253
+ series' keys in its schema (ADR 053).
254
+ - **The pane URL grammar grows by one parameter and one array of hashes.**
255
+ ADR 037 counts that as public surface. The array of hashes is the first
256
+ structured parameter in the grammar, and it is readable because each
257
+ hash is a short sentence led by its measure.
258
+ - **A chart with series carries three more data attributes** (`series`,
259
+ `label`, `axes`), and `labels`, `values` and `formatted` keep meaning
260
+ the pane's own series, so a host that subclasses the controller and
261
+ reads `datasets[0]` sees what it saw.
262
+ - The running total is summed from the buckets Groupdate returned, so it
263
+ starts at the first bucket with a row, not at the beginning of time. A
264
+ host that fixes the frame's range with `where:` starts the count there.
265
+ That is the right answer for a filtered dashboard and the surprising one
266
+ for a chart meant to begin at zero on launch day; a `where:` from the
267
+ launch date is the way to say so.
268
+ - A series by a different time dimension counts rows where that column
269
+ falls in the bucket, so a null is not counted anywhere. That is what
270
+ makes `Orders by Shipped on` the orders that shipped, with no filter.
271
+ - Up to three more queries per pane, bounded as companions are (ADR 051).
272
+ - Text labels on a canvas are not available to a screen reader, which is
273
+ as true of the lines themselves (ADR 042) and is why the table is
274
+ always there.
275
+ - Not decided here: series on a categorical pane; a series with its own
276
+ granularity or its own filter; stacking; a legend a keyboard can
277
+ operate; a second axis for a single series. Each starts from the table
278
+ above.
279
+ - What would change this decision: a host that needs a grouped bar over
280
+ categories, which reopens the colour rule and is the first thing to
281
+ revisit; or a dashboard whose series come from two models, which is an
282
+ argument about what a pane is and not about this.
@@ -0,0 +1,147 @@
1
+ ---
2
+ Date: 2026-10-29
3
+ Status: Proposed
4
+ Related: ADR 003, ADR 005, ADR 008, ADR 012, ADR 025, ADR 035, ADR 037, ADR 040, ADR 043, ADR 053
5
+ Triggers:
6
+ - a frame holding panes over more than one model
7
+ - a pane showing "That request is not allowed on this pane" after a click on another pane
8
+ - deciding what a pane does with a filter key its model does not declare
9
+ - adding a boolean to a pane, a column to janela_panes, or a parameter to the pane URL
10
+ - a setting that could live on the frame or on the pane
11
+ Topics: cross-filtering, ransack, panes, frames, urls, public-api, security
12
+ ---
13
+
14
+ # ADR 063: A Pane Can Ignore a Filter Its Model Cannot Apply
15
+
16
+ ## Context
17
+
18
+ #92, found trialling a frame that holds panes over two models: the demo's
19
+ orders, with a status, a region and a date, and the demo's page views,
20
+ with a page and a date. Clicking `paid` on an orders pane writes
21
+ `q[status_in][]=paid` to the frame (ADR 003, ADR 008), the frame hands
22
+ it to every pane, and the page views pane answers with "That request is
23
+ not allowed on this pane" where its number was.
24
+
25
+ Measured against the demo before deciding. `Query#applicable_filters`
26
+ drops only the pane's own dimension, which is the cross-filtering rule:
27
+ the pane you clicked shows the alternatives to what you picked. Every
28
+ other key goes to `Definition#filter`, where `reject_dropped_filters!`
29
+ compares what Ransack applied with what was asked and raises
30
+ `BadRequest` for the difference: `PageView does not allow filtering on
31
+ status_in`. Ransack itself, asked for `status_in` and `viewed_on_gteq`
32
+ on page views, applied `viewed_on` and dropped `status` without a word.
33
+
34
+ That refusal is deliberate and right (ADR 025). Ransack discards a
35
+ condition its allowlist does not permit, so a typo in a hand written
36
+ `q[...]` would quietly show an unfiltered number, which is the worst
37
+ thing this library can do. Nothing about that changes here. What is
38
+ wrong is only its reach: a key another pane's model declares is not a
39
+ mistake, it is a click the reader made on a pane beside this one, and
40
+ the honest answer is for this pane to stay as it is.
41
+
42
+ A key both models declare under the same name, such as a date, or one
43
+ reached through an association, already works, because it matches a
44
+ declared dimension on the second model too.
45
+
46
+ ### What was considered
47
+
48
+ **Drop undeclared keys on every pane.** The smallest change and the
49
+ wrong one: it removes the loud failure ADR 025 chose, for every host,
50
+ to suit the one frame that mixes models.
51
+
52
+ **A setting on the frame.** A frame is where the mixing happens, so a
53
+ frame that says "my panes ignore what they cannot apply" reads well.
54
+ Rejected, for how a pane is drawn. A pane is fetched on its own URL in
55
+ its own request (ADR 005), lazily and again on every cross-filter, and
56
+ a hand written `janela_frame do` block has no record at all, so the
57
+ setting would have to travel on each pane's URL anyway to reach the
58
+ request that draws it. A frame column would then be a fourth place to
59
+ say a per-pane thing, beside the row, the helper and the URL that every
60
+ other option already uses, and it could not reach a hand written frame.
61
+
62
+ **Derive it: drop a key that another pane in the same frame declares.**
63
+ The most precise rule, since a key no pane can apply is still a typo.
64
+ Rejected, because only a stored pane fetched through its frame knows
65
+ what the other panes are; a `janela_pane` on its own URL does not, and
66
+ the two shapes would behave differently for the same click.
67
+
68
+ **Have the frame controller send each pane only the keys its model
69
+ declares.** The browser does not know the models' dimensions, and
70
+ teaching it would put the allowlist in the page (ADR 025 keeps it on
71
+ the server).
72
+
73
+ ## Decision
74
+
75
+ **A pane may carry `ignore_unknown_filters`, a yes or a no. When set,
76
+ the reader's filters whose keys Ransack would not apply to the pane's
77
+ model are dropped before the query, and the pane draws as if they were
78
+ never written. Unset is exactly what a pane is today: a key the model
79
+ does not declare is refused with its reason.**
80
+
81
+ ```erb
82
+ <%= janela_frame do %>
83
+ <%= janela_pane Order, :revenue, by: :status, as: :bar %>
84
+ <%= janela_pane PageView, :views, by: :page, ignore_unknown_filters: true %>
85
+ <% end %>
86
+ ```
87
+
88
+ **Only the reader's filters are dropped.** A host's fixed `where:`
89
+ (ADR 040) and a frame's `default_where` (ADR 043) are the host's and the
90
+ analyst's own words about this pane and stay refused when wrong, as they
91
+ are validated today. A key the model does declare but with a predicate
92
+ its dimension does not allow, such as `status_matches`, is still refused:
93
+ that is a misuse of a real key, not another model's key.
94
+
95
+ **What is dropped is what Ransack would drop,** read the same way
96
+ `reject_dropped_filters!` reads it, so the two cannot disagree about
97
+ which keys belong to the model. A key that survives is applied exactly
98
+ as it would be on any pane.
99
+
100
+ **It is opt in, per pane, because the pane is what knows.** A pane is
101
+ the unit that is fetched, refused and drawn, and the analyst placing a
102
+ page views pane on an orders frame is the one who knows it will be
103
+ clicked past. A pane that never meets another model keeps the loud
104
+ failure, and a typo on it is still a typo.
105
+
106
+ **One vocabulary in three places,** as `value_labels` is (ADR 054):
107
+
108
+ - `Janela::Pane#ignore_unknown_filters`, a nullable boolean offered on
109
+ the pane form as a checkbox.
110
+ - `janela_pane` takes `ignore_unknown_filters:`.
111
+ - The pane URL carries `?ignore_unknown_filters=1`, because the server
112
+ draws the pane again from that URL on every cross-filter.
113
+
114
+ `janela_snapshot_pane` does not take it: a snapshot's filters were
115
+ fixed when it was taken and the request's are ignored (ADR 009).
116
+
117
+ **It is not part of the pane's identity** (ADR 029). What a pane does
118
+ with a stray key does not say which query it is.
119
+
120
+ ## Consequences
121
+
122
+ - A frame can hold panes over two models and a click on either side
123
+ leaves the other side standing. That is the first thing a host
124
+ building a page over events and the records they belong to tries.
125
+ - **A migration for stored frames.** `janela_panes` gains a nullable
126
+ boolean, additive, so every existing pane is unset and refused as
127
+ before. A host using stored frames runs `bin/rails
128
+ janela:install:migrations` and `db:migrate`, in `UPGRADING.md`. The
129
+ doctor's `unmigrated-columns` check is taught the column.
130
+ - **`Janela::Tools` learns the attribute** in the same change (ADR 053).
131
+ - **The pane URL grammar grows by one optional parameter,** which ADR
132
+ 037 counts as public surface.
133
+ - A pane that ignores a key is silently unfiltered by it, which is the
134
+ very thing ADR 025 refuses by default. It is accepted here because the
135
+ analyst asked for it on that pane, and because the alternative is a
136
+ pane that cannot share a frame with another model at all. The log
137
+ says nothing when a key is dropped: a dropped key is the normal case
138
+ on such a pane, not a finding.
139
+ - A dimension reached through an association is addressed by the host
140
+ as `<association>_<column>`, so two models can share a click only when
141
+ they spell the key the same. A way for a host to alias that name is a
142
+ separate question, not decided here.
143
+ - What would change this decision: a frame that needs the derived rule,
144
+ a key dropped only when another pane in the frame declares it, which
145
+ would need the pane URL to carry the frame's vocabulary and is an
146
+ argument about what a pane URL says; or a host that wants the drop
147
+ logged, which is a setting that would have to earn itself.
@@ -21,28 +21,28 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
21
21
  | Topic | ADRs |
22
22
  |-------|------|
23
23
  | **Vision, scope, forkability** | 001, 010, 012, 037, 052 |
24
- | **Open-source & host-decoupling** | 001, 022, 036, 041, 052 |
25
- | **DSL & query layer** | 002, 006, 007, 020, 025, 038, 044, 049, 051 |
24
+ | **Open-source & host-decoupling** | 001, 022, 036, 041, 052, 060, 061 |
25
+ | **DSL & query layer** | 002, 006, 007, 020, 025, 038, 044, 049, 051, 055, 062 |
26
26
  | **Dependencies** | 002, 003, 004, 006, 017, 025, 053 |
27
27
  | **Authorisation** | 002, 003, 004, 009, 017, 019, 022, 032, 033, 034, 035, 039, 040, 048, 053 |
28
28
  | **Performance & storage** | 007, 017, 025, 048, 051 |
29
- | **Ordering & formatting** | 007, 020, 038, 054 |
30
- | **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040, 043, 044, 045, 048, 049 |
31
- | **Layouts & views** | 011, 012, 016, 018, 020, 027, 039, 047, 050 |
32
- | **CSS & styling** | 016, 018, 023, 026, 027, 036, 042, 046, 047, 050 |
33
- | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044, 047, 048, 050, 051, 053, 054 |
29
+ | **Ordering & formatting** | 007, 020, 038, 054, 055, 061, 062 |
30
+ | **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040, 043, 044, 045, 048, 049, 063 |
31
+ | **Layouts & views** | 011, 012, 016, 018, 020, 027, 039, 047, 050, 059 |
32
+ | **CSS & styling** | 016, 018, 023, 026, 027, 036, 042, 046, 047, 050, 062 |
33
+ | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044, 047, 048, 050, 051, 053, 054, 055, 062, 063 |
34
34
  | **Naming rule** | 014, 023, 036 |
35
- | **JavaScript delivery & charts** | 004, 006, 026, 042, 046, 047, 054 |
36
- | **Time dimensions** | 006, 025, 045 |
37
- | **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041, 045, 047, 050, 051, 054 |
38
- | **Snapshots & publishing** | 009, 020, 028, 033, 034, 051 |
39
- | **AI agents & guidance** | 010, 015, 021, 053 |
40
- | **The doctor & checks** | 021, 025, 032, 033, 035 |
35
+ | **JavaScript delivery & charts** | 004, 006, 026, 042, 046, 047, 054, 059, 062 |
36
+ | **Time dimensions** | 006, 025, 045, 061, 062 |
37
+ | **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041, 045, 047, 050, 051, 054, 055, 061, 062, 063 |
38
+ | **Snapshots & publishing** | 009, 020, 028, 033, 034, 051, 055 |
39
+ | **AI agents & guidance** | 010, 015, 021, 053, 056, 057, 058 |
40
+ | **The doctor & checks** | 021, 025, 032, 033, 035, 057 |
41
41
  | **Releases & upgrades** | 015, 021, 032, 034, 035, 036, 037, 042, 052 |
42
42
  | **Accessibility & keyboard** | 024, 042, 045, 046, 049 |
43
- | **Security** | 003, 025, 028, 031, 032, 034, 035, 044, 052 |
43
+ | **Security** | 003, 025, 028, 031, 032, 034, 035, 044, 052, 063 |
44
44
  | **Testing** | 003 |
45
- | **Roadmap & planning** | 001, 037, 045, 046, 052, 053 |
45
+ | **Roadmap & planning** | 001, 037, 045, 046, 052, 053, 060 |
46
46
 
47
47
  ## Chronological
48
48
 
@@ -102,7 +102,16 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
102
102
  | 052 | Two People Cut Releases, Reports Go Private, and Support Is Best Effort | 2026-09-30 | Accepted |
103
103
  | 053 | An Agent Reaches Janela Through Tools the Host Scopes, and Janela Registers None | 2026-10-02 | Accepted |
104
104
  | 054 | A Chart Reads Numbers the Way Its Measure Formats Them | 2026-10-06 | Accepted |
105
+ | 055 | A Category Pane Can Be Sorted by Its Measure Either Way, or by Its Label | 2026-10-13 | Accepted |
106
+ | 056 | The Demo Counts Its Own Visits, and an Agent Arranges the Dashboard | 2026-10-13 | Accepted |
107
+ | 057 | A Host Is Told About the Agent Tools, and a Generator Sets Them Up | 2026-10-14 | Accepted |
108
+ | 058 | The Deployed Demo Is Reached Over SSH, and Its Visits Frame Survives a Reset | 2026-10-14 | Accepted |
109
+ | 059 | A Host Can Tell Janela's Own Pages That Its Layout Draws Charts | 2026-10-14 | Proposed |
110
+ | 060 | The Demo Tells Search Engines What It Is, and Shows Its Own Adoption | 2026-10-14 | Proposed |
111
+ | 061 | The Demo Speaks Brazilian Portuguese Under a Prefix, and the Gem Stays English | 2026-10-14 | Proposed |
112
+ | 062 | A Time Pane Can Accumulate, and Carry More Series on Two Axes | 2026-10-29 | Proposed |
113
+ | 063 | A Pane Can Ignore a Filter Its Model Cannot Apply | 2026-10-29 | Proposed |
105
114
 
106
115
  ## Next number
107
116
 
108
- Next ADR: 055
117
+ Next ADR: 064
data/docs/roadmap.md CHANGED
@@ -134,7 +134,8 @@ change in any release, and every change of that kind carries an entry in
134
134
  **A pane can be read.** Janela draws tables, bars, lines, doughnuts and
135
135
  pies. A table row can carry context beside its label, a single number says
136
136
  how prominent it is, a chart has a height that suits the page, its axis reads
137
- in the measure's own format, and a bar can show its value. Those
137
+ in the measure's own format, a bar can show its value, and a category pane can
138
+ be put in the order that reads. Those
138
139
  were not extra features. They were the 5% not finished, and most of them
139
140
  were found by people installing the gem rather than reading it.
140
141
 
@@ -151,9 +152,14 @@ height, a ratio measure, single value prominence, companion columns, Sprockets
151
152
  hosts, and how the project is run. 0.13.0 added tools an agent can use
152
153
  (ADR 053). 0.14.0 followed: a chart's axis reads in the measure's own format,
153
154
  so a ratio is a percentage down the side as well as in the tooltip, and a bar
154
- chart can show each bar's value (ADR 054). Both came from a real install, and
155
- the second adds a pane option, so it starts its own short soak. What 1.0 waits
156
- on now is a week of real installs of 0.14.0 ([#71](https://github.com/retail-tasker/janela/issues/71)),
155
+ chart can show each bar's value (ADR 054). 0.15.0 followed that: a category
156
+ pane can be sorted by its measure either way or by its label, so a bottom five
157
+ and a chart in date order can be drawn (ADR 055). 0.16.0 followed that: a time
158
+ pane can be a running total and carry more series on two axes, and a pane can
159
+ ignore a filter its model cannot apply (ADR 062, ADR 063). All four came from a
160
+ real install, and the last three add a pane option and a URL parameter, so the
161
+ soak starts again from 0.16.0. What 1.0 waits
162
+ on now is a week of real installs of 0.16.0 ([#71](https://github.com/retail-tasker/janela/issues/71)),
157
163
  since much of that surface was added in the last days and has only met this
158
164
  repository. When nothing surprising comes back, the surface is frozen and 1.0
159
165
  is tagged. The agent tools are the one new piece of surface: the read tools are
@@ -0,0 +1,51 @@
1
+ require "json"
2
+ require "rails/generators"
3
+
4
+ module Janela
5
+ module Generators
6
+ # Writes the server an agent reaches Janela::Tools through, and the entry
7
+ # MCP clients read to find it (ADR 057). Both belong to the host from then
8
+ # on. Janela edits nothing else: the Gemfile is told about, not changed.
9
+ class McpGenerator < Rails::Generators::Base
10
+ source_root File.expand_path("templates", __dir__)
11
+ desc "Writes bin/janela-mcp and a .mcp.json entry so an agent can arrange dashboards."
12
+
13
+ def write_server
14
+ if File.exist?(File.join(destination_root, SERVER))
15
+ say_status :exist, "#{SERVER} already there, left alone", :blue
16
+ else
17
+ template "janela-mcp.tt", SERVER
18
+ chmod SERVER, 0o755
19
+ end
20
+ end
21
+
22
+ def register_with_clients
23
+ path = File.join(destination_root, ".mcp.json")
24
+ config = File.exist?(path) ? JSON.parse(File.read(path)) : {}
25
+ servers = (config["mcpServers"] ||= {})
26
+
27
+ if servers.key?("janela")
28
+ say_status :exist, ".mcp.json already has a janela server, left alone", :blue
29
+ else
30
+ servers["janela"] = { "command" => "bundle", "args" => [ "exec", SERVER ] }
31
+ create_file ".mcp.json", JSON.pretty_generate(config) + "\n", force: true
32
+ end
33
+ end
34
+
35
+ def say_what_is_next
36
+ say
37
+ say %(Add gem "mcp" to your Gemfile and bundle: the server uses the official Ruby SDK, and Janela depends on no MCP library.) unless mcp_bundled?
38
+ say "Then open #{SERVER} and write the scope: who the agent acts for, and what that caller may read."
39
+ say "Until you do, every tool refuses to read. Start it with: bundle exec #{SERVER}"
40
+ say "Guide: docs/agents.md"
41
+ end
42
+
43
+ private
44
+ SERVER = "bin/janela-mcp".freeze
45
+
46
+ def mcp_bundled?
47
+ Gem.loaded_specs.key?("mcp")
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env ruby
2
+ # The server an agent reaches Janela's tools through (ADR 053, ADR 057). Janela
3
+ # wrote this file and it is yours now: change anything, and keep what you want.
4
+ #
5
+ # It speaks MCP over stdio. A client that reads .mcp.json starts it, so there
6
+ # is nothing to run by hand. To try it: bundle exec bin/janela-mcp
7
+ ENV["RAILS_ENV"] ||= "development"
8
+ require_relative "../config/environment"
9
+
10
+ # Removing this is a decision about who can reach real data through an agent.
11
+ # Make it on purpose, and make it only after the scope below is written.
12
+ abort "bin/janela-mcp runs in development only." unless Rails.env.development?
13
+
14
+ require "mcp"
15
+
16
+ # What may the agent read, and for whom? A request has a current user and this
17
+ # does not, so your controller's policy_scope cannot be called from here: outside
18
+ # a request it answers a different question and reads the wrong rows. Write the
19
+ # answer with the caller you mean. It is given a model class and returns a
20
+ # relation, as policy_scope does.
21
+ #
22
+ # ->(model) { model.where(account: Account.find_by!(name: "Acme")) }
23
+ #
24
+ # Resolve who is asking inside the lambda, on every call. A value looked up once
25
+ # when the server starts goes stale if that record is ever replaced, and the
26
+ # agent then sees nothing without being told why.
27
+ #
28
+ # Until it is written, every tool refuses to read.
29
+ SCOPE = lambda do |model|
30
+ raise Janela::Unscoped, "bin/janela-mcp has no scope yet. Open it and write what the agent may read for #{model.name}."
31
+ end
32
+
33
+ # Pass write: false for an agent that may look and never change anything.
34
+ def janela_server(write: false)
35
+ tools = Janela::Tools.all(write: write).map do |tool|
36
+ MCP::Tool.define(name: tool.name, description: tool.description, input_schema: tool.input_schema,
37
+ annotations: { read_only_hint: tool.read_only }) do |server_context:, **arguments|
38
+ # The scope is built per call, because who is asking is known only now.
39
+ scope = ->(model) { server_context[:scope].call(model) }
40
+ result = Janela::Tools.new(scope: scope, write: write).call(tool.name, arguments)
41
+ MCP::Tool::Response.new([ { type: "text", text: result.to_json } ])
42
+ rescue Janela::Error => error
43
+ MCP::Tool::Response.new([ { type: "text", text: error.message } ], error: true)
44
+ end
45
+ end
46
+
47
+ MCP::Server.new(name: "janela", tools: tools, server_context: { scope: yield })
48
+ end
49
+
50
+ MCP::Server::Transports::StdioTransport.new(janela_server(write: true) { SCOPE }).open