janela 0.13.0 → 0.15.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 +28 -0
- data/README.md +8 -4
- data/UPGRADING.md +47 -0
- data/app/assets/javascripts/janela/chart_controller.js +54 -3
- data/app/controllers/janela/application_controller.rb +2 -2
- data/app/controllers/janela/panes_controller.rb +1 -1
- data/app/controllers/janela/queries_controller.rb +2 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +2 -0
- data/app/helpers/janela/frames_helper.rb +26 -8
- data/app/models/janela/pane.rb +8 -1
- data/app/models/janela/query.rb +40 -7
- data/app/models/janela/snapshot.rb +2 -2
- data/app/views/janela/frames/_pane.html.erb +3 -2
- data/app/views/janela/panes/_form.html.erb +13 -0
- data/app/views/janela/queries/_query.html.erb +3 -1
- data/config/locales/en.yml +13 -0
- data/db/migrate/20261006000001_add_value_labels_to_janela_panes.rb +7 -0
- data/db/migrate/20261013000001_add_sort_to_janela_panes.rb +7 -0
- data/docs/decisions/020-formatting-belongs-to-the-measure.md +4 -1
- data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +221 -0
- 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/INDEX.md +9 -7
- data/docs/roadmap.md +20 -6
- data/lib/janela/definition.rb +35 -2
- data/lib/janela/doctor.rb +1 -1
- data/lib/janela/tools.rb +6 -3
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +19 -0
- metadata +12 -8
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Date: 2026-09-16
|
|
3
3
|
Status: Accepted
|
|
4
4
|
Related: ADR 002, ADR 009, ADR 012, ADR 018
|
|
5
|
+
Superseded in part by: ADR 054
|
|
5
6
|
Triggers:
|
|
6
7
|
- a number rendering with more precision than it means
|
|
7
8
|
- adding an option to a pane, a frame or a pane URL
|
|
@@ -74,7 +75,9 @@ back under a format declared today.
|
|
|
74
75
|
and a chart tooltip all show the string the measure produced, and the
|
|
75
76
|
chart is handed those strings rather than formatting a second time in
|
|
76
77
|
JavaScript. The chart still plots raw numbers, because an axis is a
|
|
77
|
-
scale and not a label.
|
|
78
|
+
scale and not a label. (Superseded in part by
|
|
79
|
+
ADR 054: the axis is formatted by the measure too. The chart still plots
|
|
80
|
+
raw numbers.)
|
|
78
81
|
|
|
79
82
|
## Consequences
|
|
80
83
|
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-10-06
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 004, ADR 020, ADR 029, ADR 037, ADR 038, ADR 046, ADR 047, ADR 050, ADR 053
|
|
5
|
+
Triggers:
|
|
6
|
+
- a chart's axis showing a number the tooltip shows differently
|
|
7
|
+
- drawing text on a chart canvas, or adding a Chart.js plugin
|
|
8
|
+
- adding a boolean to a pane, a column to janela_panes, or a parameter to the pane URL
|
|
9
|
+
- a ratio, a currency or a percentage on a chart
|
|
10
|
+
- a chart too dense to label every bar
|
|
11
|
+
Topics: charts, formatting, measures, public-api, panes, urls
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 054: A Chart Reads Numbers the Way Its Measure Formats Them
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
#72 and #76, raised from a real install building a client report: a share
|
|
19
|
+
of rows by category, drawn as bars, with the percentage on each bar. Two
|
|
20
|
+
things stood in the way, and both are the same gap. A chart is the one
|
|
21
|
+
place a number reaches a reader without the measure's format applied
|
|
22
|
+
(ADR 020), except the tooltip, which a reader has to hover to see.
|
|
23
|
+
|
|
24
|
+
Both were reproduced against the demo on `main` (Chart.js 4.5.1), with the
|
|
25
|
+
chart built live and the drawn scale read from it.
|
|
26
|
+
|
|
27
|
+
**The axis is raw.** A bar chart of a ratio drew ticks `0, 0.1 ... 1.0`
|
|
28
|
+
beside tooltips reading `0.0%`. Revenue drew `0, 50,000 ... 500,000` beside
|
|
29
|
+
tooltips reading `$469,097.85`. `chart_controller.js` sets
|
|
30
|
+
`scales: { y: { beginAtZero: true } }` and formats only the tooltip.
|
|
31
|
+
|
|
32
|
+
**#72 said this was never decided. It was.** ADR 020 closes its Decision
|
|
33
|
+
with "The chart still plots raw numbers, because an axis is a scale and not
|
|
34
|
+
a label." That sentence is wrong for a reader, who reads the axis as labels
|
|
35
|
+
whatever it is to Chart.js. A ratio's axis at 0.5 against a tooltip at 50.0%
|
|
36
|
+
reads as two different numbers (ADR 038 keeps the stored value a fraction
|
|
37
|
+
for the data's sake, which is not a reason for the axis to show one). The
|
|
38
|
+
rest of ADR 020 stands: the number is never rounded or transformed, and the
|
|
39
|
+
measure is the layer that owns its format.
|
|
40
|
+
|
|
41
|
+
**Tick precision does not need a rule of Janela's own.** The worry in #72
|
|
42
|
+
was that ticks fall between data points, so the precision that suits a
|
|
43
|
+
value (`25.0%`) is wrong for a tick (`25%`). Chart.js's own numeric tick
|
|
44
|
+
formatter already derives its decimals from the spacing between ticks.
|
|
45
|
+
Calling it with a ratio's ticks scaled by 100 and appending `%` gave, on the
|
|
46
|
+
live chart:
|
|
47
|
+
|
|
48
|
+
| ratio data up to | ticks |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| 0.53 | `0%, 10% ... 60%` |
|
|
51
|
+
| 0.03 | `0%, 0.5%, 1% ... 3%` |
|
|
52
|
+
| 1 | `0% ... 100%` |
|
|
53
|
+
|
|
54
|
+
Whole percentages where the spacing is whole, half percentages only where
|
|
55
|
+
the ticks really are half a percent apart. One catch: scaling by 100 leaves
|
|
56
|
+
float noise (`3.5000000000000004%`), so the scaled value has to be rounded
|
|
57
|
+
before it is formatted.
|
|
58
|
+
|
|
59
|
+
**A bar label has to fit, and nothing guarantees it does.** Measured with a
|
|
60
|
+
label drawn at each bar's end:
|
|
61
|
+
|
|
62
|
+
- The tallest bar of a default chart touches the top of the plot area, so a
|
|
63
|
+
label above it is clipped. `scales.y.grace = "10%"` left 30px of
|
|
64
|
+
headroom in a 300px chart, and Chart.js 4.5.1 supports it.
|
|
65
|
+
- A label inside the bar was never an option: the shortest bars are about
|
|
66
|
+
2px tall.
|
|
67
|
+
- With 30 bars in a 600px chart a bar was 13px wide and a label such as
|
|
68
|
+
`$88,000.00` was 60px, so neighbouring labels overlap several deep.
|
|
69
|
+
Hiding only the ones that collide would label some bars and not others
|
|
70
|
+
for a reason a reader cannot see.
|
|
71
|
+
- A negative bar ends below its base, so a label placed above the end would
|
|
72
|
+
sit on the bar.
|
|
73
|
+
- A plugin cannot be added to a chart after it is built, so the drawing has
|
|
74
|
+
to be handed over when the controller constructs the chart, as
|
|
75
|
+
`maintainAspectRatio` is for a height (ADR 047).
|
|
76
|
+
|
|
77
|
+
### What was considered
|
|
78
|
+
|
|
79
|
+
**Formatted tick strings from the server.** The server formats a value
|
|
80
|
+
(ADR 020), but the client chooses the ticks, where they fall depends on the
|
|
81
|
+
chart's size and data, and the server cannot know them. It would have to
|
|
82
|
+
format values it is never asked about.
|
|
83
|
+
|
|
84
|
+
**A `precision` of the measure's own for ticks.** A measure's precision is
|
|
85
|
+
what one value means. A scale spanning `$0` to `$500,000` in steps of
|
|
86
|
+
50,000 does not want cents, and a ratio at one decimal place would print
|
|
87
|
+
`20.0%` at every tick. It is the answer to a different question.
|
|
88
|
+
|
|
89
|
+
**A host-supplied tick callback.** A setting, to do what one formatter
|
|
90
|
+
already does for every measure. ADR 001 prefers one obvious way, and ADR 021
|
|
91
|
+
and ADR 023 only allow a setting where a judgement is made once at boot.
|
|
92
|
+
|
|
93
|
+
**`chartjs-plugin-datalabels` for the bar labels.** A dependency, and a
|
|
94
|
+
second way to format a number on a chart. ADR 004 keeps Chart.js the one
|
|
95
|
+
thing the engine ships, and an inline plugin that draws a string the server
|
|
96
|
+
already produced is about twenty lines.
|
|
97
|
+
|
|
98
|
+
**Labels on by default.** Changes every bar chart that exists, and a chart
|
|
99
|
+
of thirty bars gains nothing from it. Unset must change nothing, as it does
|
|
100
|
+
for height and prominence.
|
|
101
|
+
|
|
102
|
+
**A setting for where the label sits, or whether it fits.** Outside the end
|
|
103
|
+
is the only place that works for every bar, including the shortest and the
|
|
104
|
+
negative, and a fit that is a judgement per chart is a decision a reader of
|
|
105
|
+
the dashboard cannot see made.
|
|
106
|
+
|
|
107
|
+
**Labels on a line, or on a ring.** A line has a point per bucket, often
|
|
108
|
+
sixty or more, and a ring already prints every value beside it (ADR 046).
|
|
109
|
+
Neither gains from it.
|
|
110
|
+
|
|
111
|
+
## Decision
|
|
112
|
+
|
|
113
|
+
**A chart's axis is formatted by the measure, always, and a bar chart can
|
|
114
|
+
be asked to draw each bar's value as a label.** The first supersedes the one
|
|
115
|
+
sentence in ADR 020 above and nothing else in it. The second is opt in.
|
|
116
|
+
|
|
117
|
+
### The axis
|
|
118
|
+
|
|
119
|
+
The server sends what a tick needs as one more data attribute on the canvas:
|
|
120
|
+
the measure's `prefix`, its `suffix`, and whether it is a ratio.
|
|
121
|
+
|
|
122
|
+
```erb
|
|
123
|
+
data-janela--chart-tick-format-value='{"prefix":"$","suffix":"","ratio":false}'
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
The controller formats each tick with Chart.js's own numeric formatter and
|
|
127
|
+
adds those. A ratio's ticks are scaled by 100, rounded to remove float
|
|
128
|
+
noise, and given a `%`. The measure's `precision` is not used: the tick's
|
|
129
|
+
decimals come from the spacing between ticks, which is what answers "25%,
|
|
130
|
+
not 25.0%". It applies to a bar and a line, both of which have a value axis,
|
|
131
|
+
and it is not a setting: it is what the axis always was, now correct.
|
|
132
|
+
|
|
133
|
+
A measure with no prefix, suffix or ratio draws what it draws today.
|
|
134
|
+
|
|
135
|
+
### The labels
|
|
136
|
+
|
|
137
|
+
**A pane may carry `value_labels`, a boolean. Unset is exactly what it is
|
|
138
|
+
today.** When set on a bar chart, each bar's formatted string, the same one
|
|
139
|
+
the tooltip shows, is drawn beyond the end of the bar, above for a positive
|
|
140
|
+
value and below for a negative one. The value axis is given 10% of grace so
|
|
141
|
+
the tallest bar keeps its label in the plot.
|
|
142
|
+
|
|
143
|
+
**All or none.** The labels are drawn only if every one of them fits within
|
|
144
|
+
its category's slot, the chart area's width divided by the number of bars.
|
|
145
|
+
If any does not, none is drawn and the chart is what it was without the flag.
|
|
146
|
+
A chart never labels some bars and not others, and a host that wants the
|
|
147
|
+
labels on a dense chart makes the chart wider or the pane a bar chart of
|
|
148
|
+
fewer categories (`limit:`, ADR 007).
|
|
149
|
+
|
|
150
|
+
**One vocabulary in three places,** as height and prominence do:
|
|
151
|
+
|
|
152
|
+
```erb
|
|
153
|
+
<%= janela_pane Order, :expedited_rate, by: :status, as: :bar, value_labels: true %>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `Janela::Pane#value_labels`, a nullable boolean, offered on the pane form
|
|
157
|
+
as a checkbox.
|
|
158
|
+
- `janela_pane` and `janela_snapshot_pane` take `value_labels:`.
|
|
159
|
+
- The pane URL carries it as `?value_labels=1`, because the server draws the
|
|
160
|
+
pane again from that URL on every cross-filter.
|
|
161
|
+
|
|
162
|
+
**The name avoids `labels`.** In this codebase a chart's labels are its
|
|
163
|
+
categories: the controller's `labels` value is the x axis. `value_labels`
|
|
164
|
+
says which labels, and `values` would collide the same way with the
|
|
165
|
+
controller's `values`. It is the one name that reads the same at the helper,
|
|
166
|
+
the column, the URL and the controller (ADR 037).
|
|
167
|
+
|
|
168
|
+
**It is not part of the pane's identity.** ADR 029 identifies a frame by who
|
|
169
|
+
it is, and whether its bars carry numbers does not say which query it is. It
|
|
170
|
+
stays out of `Query.turbo_frame_id`.
|
|
171
|
+
|
|
172
|
+
**On anything that is not a bar chart it is ignored, not an error.** A
|
|
173
|
+
table, a single value, a line and a ring keep what they have, and a pane
|
|
174
|
+
switched between renderers keeps the flag, as height does on a ring
|
|
175
|
+
(ADR 047).
|
|
176
|
+
|
|
177
|
+
## Consequences
|
|
178
|
+
|
|
179
|
+
- **Every existing chart's axis can change.** A revenue chart gains a `$`
|
|
180
|
+
on its ticks and a ratio chart reads in percentages. Nothing else about it
|
|
181
|
+
moves, and a measure with no prefix, suffix or ratio is untouched. It is
|
|
182
|
+
in `CHANGELOG.md` under Changed as a visible correction, with no action for
|
|
183
|
+
a host; it is not an `UPGRADING.md` step (ADR 015), because nothing a host
|
|
184
|
+
wrote has to change. A host that overrode the tick callback on its own
|
|
185
|
+
chart is unaffected, since Janela does not set one for it.
|
|
186
|
+
- A ratio chart is readable end to end: a percentage on the axis, in the
|
|
187
|
+
tooltip, and on the bars if asked.
|
|
188
|
+
- **A migration for stored frames.** `janela_panes` gains a nullable
|
|
189
|
+
boolean `value_labels`, additive, so every existing pane is unset and
|
|
190
|
+
unchanged. A host using stored frames runs `bin/rails
|
|
191
|
+
janela:install:migrations` and `db:migrate`, as it did for `height`,
|
|
192
|
+
`prominence` and `companions`. The doctor's `unmigrated-columns` check
|
|
193
|
+
(ADR 035) is taught the new column, so a host that upgrades and skips the
|
|
194
|
+
migration is told rather than meeting an error on the pane form. A host that
|
|
195
|
+
does not use stored frames has nothing to do.
|
|
196
|
+
- **`Janela::Tools` has to learn the attribute.** Its `PANE_ATTRIBUTES` is the
|
|
197
|
+
list of a pane's own attributes an agent may set (ADR 053), and an
|
|
198
|
+
attribute not on it is refused. `value_labels` joins it in the same change as
|
|
199
|
+
the column, or an agent could arrange every other part of a pane and not
|
|
200
|
+
this one.
|
|
201
|
+
- **The pane URL grammar grows by one optional parameter.** ADR 037 counts
|
|
202
|
+
that as public surface, which is why this belongs before 1.0.
|
|
203
|
+
- **Ticks use the browser's number locale, and the tooltip uses the host's
|
|
204
|
+
delimiter** (ADR 020). A host whose `number.format.delimiter` differs from
|
|
205
|
+
its readers' browsers would see two styles on one chart. This is from how
|
|
206
|
+
Chart.js formats and is not something measured here; if it turns up in
|
|
207
|
+
practice the answer is to pass the delimiter in the same attribute, not to
|
|
208
|
+
stop using Chart.js's formatter.
|
|
209
|
+
- The all or none rule means the flag can do nothing on a dense chart, and
|
|
210
|
+
the pane form cannot say so. That is a real cost, accepted because the
|
|
211
|
+
alternative is a chart that labels bars selectively.
|
|
212
|
+
- Drawn pixels are not available to a screen reader. That is as true of the
|
|
213
|
+
bars themselves, which ADR 042 answers with the figure's caption and the
|
|
214
|
+
table beside it, and this decision does not change it.
|
|
215
|
+
- Tests read what the code produced: the drawn tick labels, and the position
|
|
216
|
+
and text of each drawn bar label, not the attribute that asked for them
|
|
217
|
+
(ADR 035).
|
|
218
|
+
- What would change this decision: a need for a label on a line or a ring,
|
|
219
|
+
which is an argument about those renderers and not about this one; or a
|
|
220
|
+
host that needs the labels to survive on a dense chart, which would be a
|
|
221
|
+
case for a step in how they are thinned and not for a placement setting.
|
data/docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-10-13
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 005, ADR 006, ADR 007, ADR 020, ADR 037, ADR 046, ADR 047, ADR 049, ADR 050, ADR 053, ADR 054
|
|
5
|
+
Triggers:
|
|
6
|
+
- adding a sort direction, or sorting a pane by something other than its measure
|
|
7
|
+
- asking for the bottom N, the lowest values, or a chart in label order
|
|
8
|
+
- a dimension whose labels are the natural order, such as a period code
|
|
9
|
+
- adding a parameter to the pane URL, or a column to janela_panes
|
|
10
|
+
- deciding where the NULL group sits in a pane's order
|
|
11
|
+
Topics: ordering, limit, panes, urls, query-layer, public-api, frames
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 055: A Category Pane Can Be Sorted by Its Measure Either Way, or by Its Label
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
ADR 007 ordered every category pane by its measure, largest first, with no
|
|
19
|
+
way to turn that off, and named what it left for later: "an 'other' bucket
|
|
20
|
+
that sums what the limit cut off, ascending order, sorting by label, and
|
|
21
|
+
paging. Each is plausible; none is needed by the first host." Two hosts
|
|
22
|
+
have now needed one each.
|
|
23
|
+
|
|
24
|
+
#75: a pane with a `limit` shows the top N and cannot show the bottom N, the
|
|
25
|
+
lowest scores rather than the highest. Reversing the result in the view
|
|
26
|
+
does not help, because the limit is applied in SQL before the host sees any
|
|
27
|
+
rows, so a reversed top ten is still the top ten. The host that raised it
|
|
28
|
+
carries a patch that gives each pane an ascending or descending order, and
|
|
29
|
+
that patch broke across releases because it copied method bodies.
|
|
30
|
+
|
|
31
|
+
#80: a chart of a measure by a dimension whose labels are the natural order,
|
|
32
|
+
such as `202603 March Grocery Independents`, where the leading `YYYYMM` makes
|
|
33
|
+
alphabetical order chronological, comes out largest first and not oldest
|
|
34
|
+
first, so a trend is unreadable. The host worked round it with a database
|
|
35
|
+
column that holds the month as a time dimension, because a time dimension is
|
|
36
|
+
the one exemption ADR 007 makes. That costs a column, and it merges any two
|
|
37
|
+
cycles that fall in the same month.
|
|
38
|
+
|
|
39
|
+
These are one decision. Both are "which end of which ordering does the pane
|
|
40
|
+
start from", they share every open question below, and building either alone
|
|
41
|
+
risks making the other's shape wrong.
|
|
42
|
+
|
|
43
|
+
### What was measured
|
|
44
|
+
|
|
45
|
+
Against the demo, on SQLite, before deciding.
|
|
46
|
+
|
|
47
|
+
**The ordering is one line.** `Definition#query` groups by the dimension and
|
|
48
|
+
orders by `measure.order_by` descending, then limits. Reversing the
|
|
49
|
+
direction and ordering by the dimension's own column both work with a
|
|
50
|
+
change to that line, and the limit follows the order: ascending by measure
|
|
51
|
+
with a limit of two returned the two smallest groups.
|
|
52
|
+
|
|
53
|
+
**A database sorts a column by the column's type.** Ordering in SQL puts a
|
|
54
|
+
numeric column in numeric order and a date column in date order, and sorts a
|
|
55
|
+
text column as text. A text label such as `202603 ...` sorts correctly
|
|
56
|
+
because its prefix is fixed width, and a text column holding `10` and `9`
|
|
57
|
+
sorts `10` before `9`, as any text sort does. The question #80 left open,
|
|
58
|
+
whether label sorting compares as text or follows the column's type, is
|
|
59
|
+
answered by letting the database do it: it follows the column's type,
|
|
60
|
+
because the column is what is being sorted.
|
|
61
|
+
|
|
62
|
+
**Text order is the database's collation, and it differs.** On SQLite
|
|
63
|
+
`B` sorts before `a`. Other databases sort case insensitively by default.
|
|
64
|
+
Janela does not read the collation and does not try to make them agree.
|
|
65
|
+
|
|
66
|
+
**The NULL group is placed differently by different databases.** SQLite sorts
|
|
67
|
+
NULL first in an ascending order. PostgreSQL, from its documentation and
|
|
68
|
+
not measured here, sorts it last. Left alone, a label ordered pane would
|
|
69
|
+
show `(none)` at the top on one host and at the bottom on another.
|
|
70
|
+
`ORDER BY column IS NULL, column` puts it last everywhere, and measured on
|
|
71
|
+
SQLite it did: `10, 202603 x, 9, B, a, b, (none)`.
|
|
72
|
+
|
|
73
|
+
**Ties have no order today.** Two groups with the same measure come out in
|
|
74
|
+
whatever order the database produces, and with a limit the choice between
|
|
75
|
+
them decides which one is shown at all. That is harmless for a top ten of
|
|
76
|
+
distinct sums and arbitrary for a bottom five of counts, where ties are the
|
|
77
|
+
norm.
|
|
78
|
+
|
|
79
|
+
### What was considered
|
|
80
|
+
|
|
81
|
+
**Order on the measure.** ADR 020 put formatting there, and the reasoning
|
|
82
|
+
does not carry over. Formatting is a fact about the number, true wherever it
|
|
83
|
+
is shown. Order is a choice about one view of it: the same revenue measure is
|
|
84
|
+
sensibly largest first in one pane, smallest first in another and in label
|
|
85
|
+
order in a third on the same page. A measure that carries an order would
|
|
86
|
+
force two measures for one number, which is the workaround ADR 002 offers
|
|
87
|
+
for variation and the wrong one here. The limit is already on the pane for
|
|
88
|
+
the same reason (ADR 007), and order and limit are read together.
|
|
89
|
+
|
|
90
|
+
**Two settings, a field and a direction** (`sort: :label, dir: :desc`). Two
|
|
91
|
+
columns and two URL parameters to say one of four things, with combinations
|
|
92
|
+
that mean nothing for a time pane. One setting with four values is the
|
|
93
|
+
vocabulary `height` and `prominence` already speak (ADR 047, ADR 050).
|
|
94
|
+
|
|
95
|
+
**Sorting in Ruby after the fetch.** Cannot work. The limit is applied in
|
|
96
|
+
SQL, so a Ruby sort of ten rows sorts the wrong ten.
|
|
97
|
+
|
|
98
|
+
**A natural or numeric aware text sort.** Not portable across databases and
|
|
99
|
+
a second query language to document. A host whose labels need it can sort by
|
|
100
|
+
a column that holds the sortable form, which is what the one host already
|
|
101
|
+
does, and which the label order now makes unnecessary for the common case.
|
|
102
|
+
|
|
103
|
+
**An "other" bucket and paging.** Still not needed by anyone. Left where
|
|
104
|
+
ADR 007 left them.
|
|
105
|
+
|
|
106
|
+
## Decision
|
|
107
|
+
|
|
108
|
+
**A category pane may carry a `sort`, one of four values, and unset is
|
|
109
|
+
exactly what a pane does today.**
|
|
110
|
+
|
|
111
|
+
| sort | order | reads as |
|
|
112
|
+
| --- | --- | --- |
|
|
113
|
+
| `desc` (default) | measure, largest first | top N |
|
|
114
|
+
| `asc` | measure, smallest first | bottom N |
|
|
115
|
+
| `label` | the dimension's own value, ascending | alphabetical, oldest first |
|
|
116
|
+
| `label_desc` | the dimension's own value, descending | newest first |
|
|
117
|
+
|
|
118
|
+
```erb
|
|
119
|
+
<%= janela_pane Order, :revenue, by: :customer, limit: 5, sort: :asc %>
|
|
120
|
+
<%= janela_pane Order, :revenue, by: :cycle, as: :bar, sort: :label %>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**It is the pane's, in the three places the pane's other options are.**
|
|
124
|
+
|
|
125
|
+
- `Janela::Pane#sort`, a nullable string column validated to the four
|
|
126
|
+
values and offered as a select on the pane form (blank is "Largest
|
|
127
|
+
first").
|
|
128
|
+
- `janela_pane` and `janela_snapshot_pane` take `sort:`.
|
|
129
|
+
- The pane URL carries it as `?sort=asc`, so the server-rendered content
|
|
130
|
+
keeps it on every cross-filter and lazy load, the same reasoning as
|
|
131
|
+
`height` (ADR 047).
|
|
132
|
+
|
|
133
|
+
The column is named `sort` and not `order`, which is a reserved word in SQL
|
|
134
|
+
and the name of a relation method, and not `order_by`, which already means
|
|
135
|
+
the SQL expression on a measure.
|
|
136
|
+
|
|
137
|
+
**The database does the work, and the limit comes after.** For `label` and
|
|
138
|
+
`label_desc` the query orders by the dimension's column, so a number, a date
|
|
139
|
+
and a string each sort as themselves, and the limit then cuts that order:
|
|
140
|
+
`sort: :label, limit: 10` is the first ten labels. For `asc` it orders by the
|
|
141
|
+
measure ascending, so `limit: 5` is the bottom five.
|
|
142
|
+
|
|
143
|
+
**The `(none)` group is always last,** in all four orders, by ordering on
|
|
144
|
+
`column IS NULL` first. It is a group like any other for the measure orders,
|
|
145
|
+
and where its measure falls it falls. For the label orders it has no
|
|
146
|
+
position in the label's own order, so it goes to the end in a way that is
|
|
147
|
+
the same on every database.
|
|
148
|
+
|
|
149
|
+
**Ties are broken by the label, ascending,** in every order, so the same
|
|
150
|
+
data always draws the same pane and a limit between tied groups always
|
|
151
|
+
shows the same one. Unset panes were arbitrary in ties and are now fixed,
|
|
152
|
+
which changes nothing anyone could have relied on.
|
|
153
|
+
|
|
154
|
+
**A time pane ignores `sort`.** A time series is ordered by time and shows
|
|
155
|
+
its whole range (ADR 007), and that stands. A `sort` on one is ignored and
|
|
156
|
+
not an error, as a height is on a ring, so a row whose dimension is changed
|
|
157
|
+
to a time dimension is not made invalid and keeps its value if it is changed
|
|
158
|
+
back. Newest first on a time pane is a different question, left open below.
|
|
159
|
+
|
|
160
|
+
**A ring and a table take it unchanged.** Both draw the result in order: a
|
|
161
|
+
ring's slices and its legend follow it, a table's rows follow it. Nothing
|
|
162
|
+
special is built for either. A ring of the bottom five by `asc` is the caller's
|
|
163
|
+
choice, as a ring of the top five already is, and ADR 046's remark that a
|
|
164
|
+
ring suits few slices stands.
|
|
165
|
+
|
|
166
|
+
**It is part of the pane's identity when set,** the way `limit` is (ADR 007),
|
|
167
|
+
because "top five" and "bottom five" of one breakdown are two different
|
|
168
|
+
answers and may share a page. It is added to `Query.turbo_frame_id` only when
|
|
169
|
+
it is not the default, so every existing frame id is unchanged. This differs
|
|
170
|
+
from `height`, which is drawing and not identity (ADR 047).
|
|
171
|
+
|
|
172
|
+
## Consequences
|
|
173
|
+
|
|
174
|
+
- **A migration for stored frames.** `janela_panes` gains a nullable `sort`,
|
|
175
|
+
additive, so every existing pane is unset and unchanged. A host that uses
|
|
176
|
+
stored frames runs `bin/rails janela:install:migrations` and `db:migrate`,
|
|
177
|
+
written in `UPGRADING.md` and the post install message (ADR 015). The
|
|
178
|
+
doctor's `unmigrated-columns` check (ADR 035) is taught the column.
|
|
179
|
+
- **`Janela::Tools` has to learn the attribute,** in the same change as the
|
|
180
|
+
column, or an agent could arrange every part of a pane except how it is
|
|
181
|
+
ordered (ADR 053). Its vocabulary names the four values.
|
|
182
|
+
- **The pane URL grammar grows by one optional parameter,** which ADR 037
|
|
183
|
+
counts as public surface and is why this belongs before 1.0. ADR 005's
|
|
184
|
+
naming table gains a row: *bottom N* is `?sort=asc&limit=N`.
|
|
185
|
+
- **Snapshots record it.** A snapshot's lookup key includes the sort, as it
|
|
186
|
+
includes the limit, so a frozen "bottom five" is not found for a request
|
|
187
|
+
for "top five" (ADR 009).
|
|
188
|
+
- **Text labels sort by the host database's collation.** Documented, not
|
|
189
|
+
smoothed over. A host that needs a different text order sorts on a column
|
|
190
|
+
that holds it.
|
|
191
|
+
- Companion columns follow the primary measure's order and limit, as they do
|
|
192
|
+
today (ADR 051).
|
|
193
|
+
- The demo's playground gets a control for it, shown only for a category
|
|
194
|
+
pane, with the declaration rewriting itself as for the others (ADR 027).
|
|
195
|
+
- A pane ordered by label no longer puts the largest value first, so a chart
|
|
196
|
+
in label order can bury the biggest bar. That is the analyst's choice and
|
|
197
|
+
the reason for asking.
|
|
198
|
+
- Not decided here: newest first on a time pane; an "other" bucket; paging;
|
|
199
|
+
a host's own ordering by an arbitrary column. Each starts from this
|
|
200
|
+
table.
|
|
201
|
+
- What would change this decision: a need for an ordering by something that
|
|
202
|
+
is neither the measure nor the label, such as a second measure, which is an
|
|
203
|
+
argument for companion columns driving an order (ADR 051) and not for more
|
|
204
|
+
values here; or a database where `column IS NULL` is not portable.
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -22,20 +22,20 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
22
22
|
|-------|------|
|
|
23
23
|
| **Vision, scope, forkability** | 001, 010, 012, 037, 052 |
|
|
24
24
|
| **Open-source & host-decoupling** | 001, 022, 036, 041, 052 |
|
|
25
|
-
| **DSL & query layer** | 002, 006, 007, 020, 025, 038, 044, 049, 051 |
|
|
25
|
+
| **DSL & query layer** | 002, 006, 007, 020, 025, 038, 044, 049, 051, 055 |
|
|
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 |
|
|
29
|
+
| **Ordering & formatting** | 007, 020, 038, 054, 055 |
|
|
30
30
|
| **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040, 043, 044, 045, 048, 049 |
|
|
31
31
|
| **Layouts & views** | 011, 012, 016, 018, 020, 027, 039, 047, 050 |
|
|
32
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 |
|
|
33
|
+
| **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044, 047, 048, 050, 051, 053, 054, 055 |
|
|
34
34
|
| **Naming rule** | 014, 023, 036 |
|
|
35
|
-
| **JavaScript delivery & charts** | 004, 006, 026, 042, 046, 047 |
|
|
35
|
+
| **JavaScript delivery & charts** | 004, 006, 026, 042, 046, 047, 054 |
|
|
36
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 |
|
|
38
|
-
| **Snapshots & publishing** | 009, 020, 028, 033, 034, 051 |
|
|
37
|
+
| **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041, 045, 047, 050, 051, 054, 055 |
|
|
38
|
+
| **Snapshots & publishing** | 009, 020, 028, 033, 034, 051, 055 |
|
|
39
39
|
| **AI agents & guidance** | 010, 015, 021, 053 |
|
|
40
40
|
| **The doctor & checks** | 021, 025, 032, 033, 035 |
|
|
41
41
|
| **Releases & upgrades** | 015, 021, 032, 034, 035, 036, 037, 042, 052 |
|
|
@@ -101,7 +101,9 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
101
101
|
| 051 | A Table Can Carry Companion Columns, and a Fact Is Shown Only When It Is Shared | 2026-09-30 | Accepted |
|
|
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
|
+
| 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 |
|
|
104
106
|
|
|
105
107
|
## Next number
|
|
106
108
|
|
|
107
|
-
Next ADR:
|
|
109
|
+
Next ADR: 056
|
data/docs/roadmap.md
CHANGED
|
@@ -133,7 +133,9 @@ change in any release, and every change of that kind carries an entry in
|
|
|
133
133
|
|
|
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
|
-
how prominent it is,
|
|
136
|
+
how prominent it is, a chart has a height that suits the page, its axis reads
|
|
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
|
|
137
139
|
were not extra features. They were the 5% not finished, and most of them
|
|
138
140
|
were found by people installing the gem rather than reading it.
|
|
139
141
|
|
|
@@ -147,8 +149,15 @@ working is a check nobody should rely on.
|
|
|
147
149
|
|
|
148
150
|
The near ground is clear. Every issue that was in 1.0 has shipped: chart
|
|
149
151
|
height, a ratio measure, single value prominence, companion columns, Sprockets
|
|
150
|
-
hosts, and how the project is run. 0.13.0
|
|
151
|
-
|
|
152
|
+
hosts, and how the project is run. 0.13.0 added tools an agent can use
|
|
153
|
+
(ADR 053). 0.14.0 followed: a chart's axis reads in the measure's own format,
|
|
154
|
+
so a ratio is a percentage down the side as well as in the tooltip, and a bar
|
|
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). All three came from a real
|
|
158
|
+
install, and the last two add a pane option and a URL parameter, so the soak
|
|
159
|
+
starts again from 0.15.0. What 1.0 waits
|
|
160
|
+
on now is a week of real installs of 0.15.0 ([#71](https://github.com/retail-tasker/janela/issues/71)),
|
|
152
161
|
since much of that surface was added in the last days and has only met this
|
|
153
162
|
repository. When nothing surprising comes back, the surface is frozen and 1.0
|
|
154
163
|
is tagged. The agent tools are the one new piece of surface: the read tools are
|
|
@@ -171,9 +180,14 @@ release, and being on this list is not a refusal.
|
|
|
171
180
|
- **Telling a frame to refresh** ([#68](https://github.com/retail-tasker/janela/issues/68)).
|
|
172
181
|
One action a host's own timer or push can call, and no timer or stream of
|
|
173
182
|
Janela's own (ADR 048). Additive, so it can land after 1.0.
|
|
174
|
-
- **
|
|
175
|
-
|
|
176
|
-
|
|
183
|
+
- **A frame toolbar** ([#77](https://github.com/retail-tasker/janela/issues/77)).
|
|
184
|
+
A small strip of standard controls a host does not have to build, starting
|
|
185
|
+
with full screen. Additive, so it can land after 1.0, and it needs a decision
|
|
186
|
+
record first.
|
|
187
|
+
- **A theme panel in the demo** ([#84](https://github.com/retail-tasker/janela/issues/84)).
|
|
188
|
+
Setting how every pane is presented, starting with colours, as a page of
|
|
189
|
+
controls over the properties a theme already publishes. The demo, not the
|
|
190
|
+
gem, and any property beyond colour needs a decision record first.
|
|
177
191
|
- **A command palette for the demo** ([#41](https://github.com/retail-tasker/janela/issues/41)).
|
|
178
192
|
The demo site, not the gem.
|
|
179
193
|
- **A scroll drift on the gallery page** ([#45](https://github.com/retail-tasker/janela/issues/45)).
|
data/lib/janela/definition.rb
CHANGED
|
@@ -4,6 +4,11 @@ module Janela
|
|
|
4
4
|
# applied when a host asks for none, and as the bound on one filter's values.
|
|
5
5
|
MAXIMUM = 1000
|
|
6
6
|
|
|
7
|
+
# How a category pane is ordered (ADR 055): by its measure, largest or
|
|
8
|
+
# smallest first, or by the dimension's own value, ascending or descending.
|
|
9
|
+
# The first is what a pane did before there was a choice.
|
|
10
|
+
SORTS = %w[desc asc label label_desc].freeze
|
|
11
|
+
|
|
7
12
|
attr_reader :model, :measures, :dimensions
|
|
8
13
|
|
|
9
14
|
# The class whose janela block this is, which a subclass shares with the
|
|
@@ -44,8 +49,9 @@ module Janela
|
|
|
44
49
|
# keys: narrows a categorical grouping to the labels a companion column is
|
|
45
50
|
# being fetched for (ADR 051), so it is the primary's rows and not the
|
|
46
51
|
# companion's own top 1000.
|
|
47
|
-
def query(measure_name, by: nil, where: {}, on: nil, granularity: nil, limit: nil, keys: nil)
|
|
52
|
+
def query(measure_name, by: nil, where: {}, on: nil, granularity: nil, limit: nil, keys: nil, sort: nil)
|
|
48
53
|
measure = measure!(measure_name)
|
|
54
|
+
sort = sort!(sort)
|
|
49
55
|
relation = filter(on || model.all, where)
|
|
50
56
|
return measure.apply(relation) if by.nil?
|
|
51
57
|
|
|
@@ -57,7 +63,7 @@ module Janela
|
|
|
57
63
|
bucketed(measure, dimension, relation, granularity).transform_keys { |bucket| dimension.label(bucket, granularity) }
|
|
58
64
|
else
|
|
59
65
|
relation = within_keys(relation, dimension, keys) if keys
|
|
60
|
-
grouped = relation.group(dimension.attribute).order(Arel.sql(
|
|
66
|
+
grouped = relation.group(dimension.attribute).order(Arel.sql(ordering(measure, dimension, sort)))
|
|
61
67
|
grouped = grouped.limit(limit ? limit!(limit) : MAXIMUM)
|
|
62
68
|
measure.apply(grouped).transform_keys { |value| value.nil? ? Dimension::NONE : value }
|
|
63
69
|
end
|
|
@@ -124,6 +130,15 @@ module Janela
|
|
|
124
130
|
nil # no database to ask yet; a query will raise on its own if it cannot run
|
|
125
131
|
end
|
|
126
132
|
|
|
133
|
+
# nil is the default, so a caller that never heard of a sort is unchanged.
|
|
134
|
+
def sort!(value)
|
|
135
|
+
return "desc" if value.blank?
|
|
136
|
+
|
|
137
|
+
value = value.to_s
|
|
138
|
+
raise BadRequest, "sort must be one of #{SORTS.join(', ')}, got #{value.inspect}" unless SORTS.include?(value)
|
|
139
|
+
value
|
|
140
|
+
end
|
|
141
|
+
|
|
127
142
|
def limit!(value)
|
|
128
143
|
limit = Integer(value, exception: false)
|
|
129
144
|
raise BadRequest, "limit must be a whole number from 1 to #{MAXIMUM}, got #{value.inspect}" unless limit&.between?(1, MAXIMUM)
|
|
@@ -147,6 +162,24 @@ module Janela
|
|
|
147
162
|
end
|
|
148
163
|
|
|
149
164
|
private
|
|
165
|
+
# The label comes second in a measure order so that groups tied on the
|
|
166
|
+
# measure come out the same way every time, and a limit between them
|
|
167
|
+
# always shows the same one. IS NULL puts the (none) group last wherever
|
|
168
|
+
# the database would put it: SQLite sorts NULL first in an ascending
|
|
169
|
+
# order and PostgreSQL last, and a label order would otherwise differ by
|
|
170
|
+
# host (ADR 055). The database sorts the column as its own type, so a
|
|
171
|
+
# number is in numeric order and a date in date order.
|
|
172
|
+
def ordering(measure, dimension, sort)
|
|
173
|
+
label = dimension.quoted_column
|
|
174
|
+
by_label = "#{label} IS NULL, #{label}"
|
|
175
|
+
case sort
|
|
176
|
+
when "asc" then "#{measure.order_by} ASC, #{by_label} ASC"
|
|
177
|
+
when "label" then "#{by_label} ASC"
|
|
178
|
+
when "label_desc" then "#{by_label} DESC"
|
|
179
|
+
else "#{measure.order_by} DESC, #{by_label} ASC"
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
150
183
|
def within_keys(relation, dimension, keys)
|
|
151
184
|
labels = keys - [ Dimension::NONE ]
|
|
152
185
|
condition = dimension.attribute.in(labels)
|
data/lib/janela/doctor.rb
CHANGED
|
@@ -148,7 +148,7 @@ module Janela
|
|
|
148
148
|
# the same thing twice.
|
|
149
149
|
ADDED_COLUMNS = {
|
|
150
150
|
"Janela::Frame" => %w[key default_model default_where],
|
|
151
|
-
"Janela::Pane" => %w[kind heading body link partial height prominence companions],
|
|
151
|
+
"Janela::Pane" => %w[kind heading body link partial height prominence companions value_labels sort],
|
|
152
152
|
"Janela::Snapshot" => %w[owner_type owner_id]
|
|
153
153
|
}.freeze
|
|
154
154
|
|
data/lib/janela/tools.rb
CHANGED
|
@@ -17,8 +17,8 @@ module Janela
|
|
|
17
17
|
|
|
18
18
|
# The attributes of a pane a caller may set: the ones the README names. The
|
|
19
19
|
# frame, the position and the timestamps are not an agent's to write.
|
|
20
|
-
PANE_ATTRIBUTES = %w[kind model measure dimension renderer granularity limit span title height prominence
|
|
21
|
-
companions heading body link partial].freeze
|
|
20
|
+
PANE_ATTRIBUTES = %w[kind model measure dimension renderer granularity limit sort span title height prominence
|
|
21
|
+
value_labels companions heading body link partial].freeze
|
|
22
22
|
DIRECTIONS = %w[up down].freeze
|
|
23
23
|
READS = %w[describe_vocabulary list_frames get_frame read_pane].freeze
|
|
24
24
|
WRITES = %w[add_pane update_pane remove_pane move_pane].freeze
|
|
@@ -64,7 +64,8 @@ module Janela
|
|
|
64
64
|
{ models: Janela.definitions.map { |definition| model_hash(definition) },
|
|
65
65
|
renderers: Janela.renderers.map(&:to_s),
|
|
66
66
|
granularities: Janela.granularities.map(&:to_s),
|
|
67
|
-
limits: Pane::OFFERED_LIMITS
|
|
67
|
+
limits: Pane::OFFERED_LIMITS,
|
|
68
|
+
sorts: Janela.sorts }
|
|
68
69
|
end
|
|
69
70
|
|
|
70
71
|
def list_frames
|
|
@@ -205,10 +206,12 @@ module Janela
|
|
|
205
206
|
renderer: { type: "string", enum: Janela.renderers.map(&:to_s) },
|
|
206
207
|
granularity: { type: "string", enum: Janela.granularities.map(&:to_s) },
|
|
207
208
|
limit: { type: "integer", minimum: Pane::LIMITS.min, maximum: Pane::LIMITS.max },
|
|
209
|
+
sort: { type: "string", enum: Janela.sorts },
|
|
208
210
|
span: { type: "integer", minimum: Pane::SPANS.min, maximum: Pane::SPANS.max },
|
|
209
211
|
title: { type: "string" },
|
|
210
212
|
height: { type: "integer", enum: Pane::HEIGHTS.to_a },
|
|
211
213
|
prominence: { type: "integer", enum: Pane::PROMINENCES.to_a },
|
|
214
|
+
value_labels: { type: "boolean", description: "Draw each bar's value on a bar chart; other renderers ignore it" },
|
|
212
215
|
companions: { type: "array", items: { type: "string" } },
|
|
213
216
|
heading: { type: "string" },
|
|
214
217
|
body: { type: "string" },
|
data/lib/janela/version.rb
CHANGED