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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +26 -8
- data/UPGRADING.md +59 -0
- data/app/assets/javascripts/janela/chart_controller.js +148 -42
- data/app/assets/stylesheets/janela.css +7 -0
- data/app/controllers/janela/panes_controller.rb +2 -2
- data/app/controllers/janela/queries_controller.rb +12 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +2 -0
- data/app/helpers/janela/frames_helper.rb +37 -9
- data/app/models/janela/pane.rb +33 -2
- data/app/models/janela/query.rb +252 -27
- data/app/models/janela/snapshot.rb +2 -2
- data/app/views/janela/frames/show.html.erb +2 -2
- data/app/views/janela/panes/_form.html.erb +53 -0
- data/app/views/janela/queries/_query.html.erb +18 -5
- data/config/locales/en.yml +26 -1
- data/db/migrate/20261013000001_add_sort_to_janela_panes.rb +7 -0
- data/db/migrate/20261029000001_add_series_to_janela_panes.rb +9 -0
- data/db/migrate/20261029000003_add_ignore_unknown_filters_to_janela_panes.rb +8 -0
- data/docs/agents.md +33 -2
- data/docs/decisions/018-a-table-is-the-universal-renderer.md +1 -0
- data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +1 -0
- data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +1 -1
- data/docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md +204 -0
- data/docs/decisions/056-the-demo-counts-its-own-visits-and-an-agent-arranges-the-dashboard.md +51 -0
- data/docs/decisions/057-a-host-is-told-about-the-agent-tools-and-a-generator-sets-them-up.md +197 -0
- data/docs/decisions/058-the-deployed-demo-is-reached-over-ssh-and-its-visits-frame-survives-a-reset.md +94 -0
- data/docs/decisions/059-a-host-can-tell-janelas-own-pages-that-its-layout-draws-charts.md +91 -0
- data/docs/decisions/060-the-demo-tells-search-engines-what-it-is-and-shows-its-own-adoption.md +79 -0
- data/docs/decisions/061-the-demo-speaks-brazilian-portuguese-under-a-prefix-and-the-gem-stays-english.md +118 -0
- data/docs/decisions/062-a-time-pane-can-accumulate-and-carry-more-series-on-two-axes.md +282 -0
- data/docs/decisions/063-a-pane-can-ignore-a-filter-its-model-cannot-apply.md +147 -0
- data/docs/decisions/INDEX.md +25 -16
- data/docs/roadmap.md +10 -4
- data/lib/generators/janela/mcp/mcp_generator.rb +51 -0
- data/lib/generators/janela/mcp/templates/janela-mcp.tt +50 -0
- data/lib/janela/definition.rb +73 -4
- data/lib/janela/dimension.rb +9 -1
- data/lib/janela/doctor.rb +10 -1
- data/lib/janela/measure.rb +8 -0
- data/lib/janela/tools.rb +37 -4
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +12 -0
- 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.
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -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:
|
|
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,
|
|
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).
|
|
155
|
-
|
|
156
|
-
|
|
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
|