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.
@@ -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.
@@ -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.
@@ -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: 054
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, and a chart has a height that suits the page. Those
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 is out, and adds tools an agent
151
- can use (ADR 053). What 1.0 waits on now is a week of real installs ([#71](https://github.com/retail-tasker/janela/issues/71)),
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
- - **Vitral's own palette** ([#67](https://github.com/retail-tasker/janela/issues/67)).
175
- The optional theme setting its own series colours instead of Janela's
176
- neutral ones.
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)).
@@ -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("#{measure.order_by} DESC"))
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" },
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.13.0"
4
+ VERSION = "0.15.0"
5
5
  end