janela 0.14.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 69f4c0147424e4d9350bcee373b832e922f54b1686acce5c80fa776432a288c6
4
- data.tar.gz: ec2f91ec4c87dcfeb49154b54e0cda105f293e8b56580fb774750011fb9277df
3
+ metadata.gz: 9b6b7c6d32876fc33b9c40e07278094bc901c548ac355d30f2d4cd2048f41cde
4
+ data.tar.gz: c60ff46e6023319430c63a53ee5bae675fbf98541e6639982b62c5d79cd0b76b
5
5
  SHA512:
6
- metadata.gz: 38e32300abc75aa9b738d63b2d7f638dff0d9f153e19aa32f1cb5cbd422702c4e236bc93b87e7c4474836bbd3bfddfad4f83fbaa95d523efee8e6b82b6246b12
7
- data.tar.gz: fe82fff29b230ecab68eb8c14e162d214ee9c1666ead178145c73ffe776e635b8acd59fa8cc1df1011a9f091b2d50e9d9fe299e67142657ed59e69fa1d70d120
6
+ metadata.gz: a48f8cd95951b00dfd10bf9d3a4ee9476bf486cd3660618a83f561259e61c61e22289cbce2647fe75b8463d14d25aa710354d760ad549bbf51e25d5ae67cdecb
7
+ data.tar.gz: 92f1c1a0b289f523a81b4a00d6c7c63b50d480e199d0ac42ffb0db34dc98064d4b9b44b01e0dc65d5f485fcbd4e6eb6a9d897401ad197522c66ad7541cbf85a0
data/CHANGELOG.md CHANGED
@@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.15.0] - 2026-10-13
9
+
10
+ ### Added
11
+
12
+ - **A category pane can be sorted by its measure either way, or by its label.** `janela_pane Order, :revenue, by: :customer, limit: 5, sort: :asc` shows the bottom five, and `janela_pane Order, :revenue, by: :cycle, as: :bar, sort: :label` draws a chart in the dimension's own order. `sort` takes `desc` (the default, and what every pane did before), `asc`, `label` and `label_desc`, as a select on the pane form and in `Janela::Tools`, and travels in the pane URL as `?sort=asc`. The limit follows the order, so `asc` with a limit is the real smallest rows and not the largest turned round, which the view could not do. A label order is the database sorting the column, so a number is in numeric order, a date in date order and a string in the database's collation. The `(none)` group is last in every order, because SQLite and PostgreSQL disagree about where NULL sorts. A time pane is always in time order and ignores it. Stored panes need a migration; see `UPGRADING.md`. `Janela.sorts` lists the four for a page that offers them as controls (ADR 055, #75, #80).
13
+
14
+ ### Changed
15
+
16
+ - **Groups tied on the measure now come out by label.** Two groups with the same value used to come out in whatever order the database produced, which with a `limit` decided which of them was shown at all. They are now in label order, ascending, in either direction. A pane with no ties draws exactly what it did. A set `sort` is part of the pane's frame id (`..._top5_asc`), and an unset one leaves every existing id as it was (ADR 055).
17
+
8
18
  ## [0.14.0] - 2026-10-07
9
19
 
10
20
  ### Added
@@ -289,6 +299,7 @@ First alpha, installed from GitHub for testing in a single host application.
289
299
  - Only models that declare a `janela` block are addressable over HTTP.
290
300
  - ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
291
301
 
302
+ [0.15.0]: https://github.com/retail-tasker/janela/releases/tag/v0.15.0
292
303
  [0.14.0]: https://github.com/retail-tasker/janela/releases/tag/v0.14.0
293
304
  [0.13.0]: https://github.com/retail-tasker/janela/releases/tag/v0.13.0
294
305
  [0.12.0]: https://github.com/retail-tasker/janela/releases/tag/v0.12.0
data/README.md CHANGED
@@ -27,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It is a
27
27
 
28
28
  ```ruby
29
29
  # Gemfile
30
- gem "janela", "~> 0.14"
30
+ gem "janela", "~> 0.15"
31
31
  ```
32
32
 
33
33
  ```ruby
@@ -224,7 +224,7 @@ Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one r
224
224
  <% end %>
225
225
  ```
226
226
 
227
- A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars. `as:` is `:table` by default, `:bar` for a Chart.js bar chart, `:doughnut` or `:pie` for a part-to-whole split, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
227
+ A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars, and `sort:` changes which ten (see below). `as:` is `:table` by default, `:bar` for a Chart.js bar chart, `:doughnut` or `:pie` for a part-to-whole split, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
228
228
 
229
229
  A doughnut or a pie is drawn on the server as SVG with a legend of buttons beside it, so it is in the page before any JavaScript runs and can be operated from the keyboard through the legend. Bars, doughnut slices and pie slices are drawn in a palette of eight colours by position, first to eighth, and every value after the eighth in one neutral, so a ring suits a handful of values: `limit: 8` keeps it readable. A ring cannot show a negative value, so a pane with one is drawn as a table and says so. The palette is `--janela-series-1` to `--janela-series-8` and `--janela-series-other` in [docs/theming.md](docs/theming.md) (ADR 046).
230
230
 
@@ -232,6 +232,8 @@ A doughnut or a pie is drawn on the server as SVG with a legend of buttons besid
232
232
 
233
233
  **Prominence.** A single value is `2rem` unless the pane says how much it matters: `prominence: 1` is a footnote at `1.25rem`, `2` is what it already is, and `3` is the hero number at `3.5rem` (`janela_pane Order, :orders, prominence: 3`). Three steps rather than a length, the same as `span` and `height`, so a stored pane takes it too, from the pane form. The label stays small at every step. With none set nothing changes, and a table, a chart and a ring ignore one (ADR 050).
234
234
 
235
+ **Sorting.** A breakdown by a category is largest first by default. `sort: :asc` puts the smallest first, so `limit: 5` is the bottom five, and `sort: :label` and `sort: :label_desc` order by the dimension's own value, which suits a label whose text is already the natural order, such as a period code (`janela_pane Order, :revenue, by: :cycle, as: :bar, sort: :label`). The limit follows the order. A number or a date column sorts as itself, and text sorts by your database's collation, which is not the same everywhere. The `(none)` group is always last. Groups that tie on the measure come out by label. It is a select on the pane form, `sort:` in `Janela::Tools`, and `?sort=` in the pane URL. A time series is always in time order and ignores it (ADR 055).
236
+
235
237
  **Value labels.** A bar chart shows a value only in its tooltip unless the pane asks for it on the bar: `value_labels: true` (`janela_pane Order, :expedited_rate, by: :channel, as: :bar, value_labels: true`), or the checkbox on the pane form. The label is the same string the tooltip shows, the measure's own format, drawn beyond the end of the bar: above a positive one and below a negative. It is all or none: if any label is wider than its bar's share of the chart, none is drawn, rather than some bars carrying a number and others not. Make the chart wider or `limit:` the bars. With none set nothing changes, and a line, a ring, a table and a single value ignore it (ADR 054).
236
238
 
237
239
  **Companion columns.** A table can carry up to three more columns beside its label, each a measure or a dimension the model declares: `janela_pane Order, :expedited_rate, by: :customer, companions: [:orders, :region]` reads as customer, expedited rate, orders and region, under a header row. A measure companion is its own number, formatted as it declares, so a rate can sit beside the count behind it: 75% of four and 75% of forty are different things to act on. A dimension companion shows a value only where every row of that label's group shares one, and is blank where they differ, since grouping by it would show a label twice and picking one would show an arbitrary value as fact. Order, limit, filters and clicking a label are the primary measure's and are unchanged. Only a table draws companions, a stored snapshot shows the base table, and a time pane may carry measures but not dimensions. Each companion is one more query, which is why it is three at most (ADR 051).
@@ -563,7 +565,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
563
565
 
564
566
  ## Status
565
567
 
566
- **v0.14.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar, line, doughnut and pie charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames found by owner and key, panes that hold words or a host partial as well as a query, a host-fixed filter no click can remove and a frame's own permanent one beside it, STI subclasses, the engine's own pages for reading and editing them, bar charts that read in their measure's format and can show each bar's value, tools an agent can use to read and arrange a dashboard, and the optional vitral theme work and are covered by unit and real-browser tests, with the classes a theme may target documented in [Theming Janela](docs/theming.md). Not yet built: a visual editor, and narrowing a time pane's own granularity by clicking one of its buckets (a click on a bucket does filter the others). [Vista](docs/roadmap.md), the roadmap, says what 1.0 means and which of these are in it; open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
568
+ **v0.15.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar, line, doughnut and pie charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames found by owner and key, panes that hold words or a host partial as well as a query, a host-fixed filter no click can remove and a frame's own permanent one beside it, STI subclasses, the engine's own pages for reading and editing them, bar charts that read in their measure's format and can show each bar's value, category panes that can be ordered either way by their measure or by their label, tools an agent can use to read and arrange a dashboard, and the optional vitral theme work and are covered by unit and real-browser tests, with the classes a theme may target documented in [Theming Janela](docs/theming.md). Not yet built: a visual editor, and narrowing a time pane's own granularity by clicking one of its buckets (a click on a bucket does filter the others). [Vista](docs/roadmap.md), the roadmap, says what 1.0 means and which of these are in it; open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
567
569
 
568
570
  ## Development
569
571
 
data/UPGRADING.md CHANGED
@@ -12,6 +12,31 @@ bin/rails janela:doctor
12
12
 
13
13
  It reads your application and lists what still needs changing.
14
14
 
15
+ ## 0.14.0 to 0.15.0
16
+
17
+ One migration step, if you use stored frames.
18
+
19
+ **Take the pane migration.**
20
+
21
+ A category pane can now be sorted (ADR 055). `janela_panes` gains a nullable
22
+ string `sort`:
23
+
24
+ ```bash
25
+ bin/rails janela:install:migrations
26
+ bin/rails db:migrate
27
+ ```
28
+
29
+ Every existing pane keeps it nil and is ordered exactly as before, largest
30
+ first. If you never use stored frames, there is nothing to do.
31
+ `bin/rails janela:doctor` names the column if a stored-frames host skips the
32
+ step.
33
+
34
+ **If you carry a patch that orders panes,** it can go: `sort: :asc` and
35
+ `sort: :label` are the options it was written to add. Groups that tie on the
36
+ measure now come out in label order where they used to come out in the
37
+ database's, so a pane that was relying on that has nothing to change and is
38
+ now the same every time.
39
+
15
40
  ## 0.13.0 to 0.14.0
16
41
 
17
42
  One migration step, if you use stored frames.
@@ -76,7 +76,7 @@ module Janela
76
76
  end
77
77
 
78
78
  def pane_params
79
- params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :height, :prominence, :value_labels, :span, :title,
79
+ params.expect(pane: [ :kind, :model, :measure, :dimension, :renderer, :granularity, :limit, :sort, :height, :prominence, :value_labels, :span, :title,
80
80
  :heading, :body, :link, :partial, { companions: [] } ])
81
81
  end
82
82
 
@@ -8,6 +8,7 @@ module Janela
8
8
  renderer: params.fetch(:as, "table"),
9
9
  granularity: params[:granularity],
10
10
  limit: params[:limit],
11
+ sort: params[:sort],
11
12
  height: params[:height],
12
13
  prominence: params[:prominence],
13
14
  value_labels: params[:value_labels],
@@ -13,6 +13,7 @@ module Janela
13
13
  renderer: params.fetch(:as, "table"),
14
14
  granularity: params[:granularity],
15
15
  limit: params[:limit],
16
+ sort: params[:sort],
16
17
  height: params[:height],
17
18
  prominence: params[:prominence],
18
19
  value_labels: params[:value_labels],
@@ -34,13 +34,13 @@ module Janela
34
34
  # server draws the pane from it again on every cross-filter. It is not part
35
35
  # of the frame's id: how tall a pane is does not say which query it is
36
36
  # (ADR 047, ADR 029).
37
- def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, id: nil)
38
- query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, height: height, prominence: prominence,
37
+ def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, id: nil)
38
+ query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, sort: sort, height: height, prominence: prominence,
39
39
  value_labels: (1 if value_labels), companions: companions.presence,
40
40
  where: @janela_fixed_filters.presence }.compact
41
41
  base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
42
42
 
43
- turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
43
+ turbo_frame_tag id || Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, sort: sort),
44
44
  src: janela_with_page_filters(base),
45
45
  loading: :lazy,
46
46
  data: { janela__frame_target: "pane", janela_src: base }
@@ -48,12 +48,12 @@ module Janela
48
48
 
49
49
  # A pane as it was when the snapshot was taken: same shape as janela_pane,
50
50
  # not part of the live frame's filter state (ADR 009).
51
- def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil, height: nil, prominence: nil, value_labels: nil, companions: nil)
52
- query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, height: height,
51
+ def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil)
52
+ query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit, sort: sort, height: height,
53
53
  prominence: prominence, value_labels: (1 if value_labels), companions: companions.presence }.compact
54
54
  src = janela_routes.snapshot_pane_path(snapshot, model.model_name.route_key, measure, by, **query)
55
55
 
56
- turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, snapshot: snapshot),
56
+ turbo_frame_tag Query.turbo_frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, sort: sort, snapshot: snapshot),
57
57
  src: src, loading: :lazy
58
58
  end
59
59
 
@@ -14,6 +14,10 @@ module Janela
14
14
  # How prominent a single value is drawn (ADR 050).
15
15
  PROMINENCES = Query::PROMINENCES
16
16
  LIMITS = (1..1000).freeze
17
+
18
+ # How a category pane is ordered (ADR 055). Nil is the default, largest
19
+ # first by the measure.
20
+ SORTS = Definition::SORTS
17
21
  # A form cannot offer a thousand options, and these are the row counts a
18
22
  # dashboard actually asks for. Any limit inside LIMITS is still valid.
19
23
  OFFERED_LIMITS = [ 5, 10, 20, 50, 100 ].freeze
@@ -38,8 +42,11 @@ module Janela
38
42
  validates :kind, inclusion: { in: KINDS }
39
43
  validates :span, inclusion: { in: SPANS }
40
44
  validates :limit, inclusion: { in: LIMITS }, allow_nil: true
45
+ validates :sort, inclusion: { in: SORTS }, allow_nil: true
41
46
  validates :height, inclusion: { in: HEIGHTS }, allow_nil: true
42
47
  validates :prominence, inclusion: { in: PROMINENCES }, allow_nil: true
48
+ # A form sends an empty string for "Largest first", which is unset.
49
+ normalizes :sort, with: ->(value) { value.presence }
43
50
  before_validation :normalise_companions
44
51
  validate :companions_are_declared
45
52
  validates :measure, presence: true, if: :query?
@@ -84,7 +91,7 @@ module Janela
84
91
  # instead (ADR 018).
85
92
  def query(filters: {}, fixed: {}, renderer: self.renderer)
86
93
  Query.new(definition: definition, measure: measure.to_sym, dimension: dimension.presence&.to_sym,
87
- renderer: renderer, granularity: granularity, limit: limit, height: height, prominence: prominence, value_labels: value_labels, companions: companions, filters: filters, fixed: fixed,
94
+ renderer: renderer, granularity: granularity, limit: limit, sort: sort, height: height, prominence: prominence, value_labels: value_labels, companions: companions, filters: filters, fixed: fixed,
88
95
  default: frame.default_for(definition.model), title: title)
89
96
  end
90
97
 
@@ -33,17 +33,18 @@ module Janela
33
33
  # dimension's shared fact, by label.
34
34
  Companion = Struct.new(:name, :header, :fact, :cells, keyword_init: true)
35
35
 
36
- attr_reader :definition, :measure, :dimension, :renderer, :limit, :height, :prominence, :value_labels, :companions, :filters, :fixed, :default, :snapshot
36
+ attr_reader :definition, :measure, :dimension, :renderer, :limit, :sort, :height, :prominence, :value_labels, :companions, :filters, :fixed, :default, :snapshot
37
37
 
38
38
  # The helper renders the turbo frame and the controller renders its
39
39
  # replacement, so both derive the id the same way from the same parameters.
40
- def self.turbo_frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, snapshot: nil)
40
+ def self.turbo_frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, sort: nil, snapshot: nil)
41
41
  parts = [ "janela", ("snapshot_#{snapshot.id}" if snapshot), model.model_name.route_key, measure, by,
42
- (granularity if by), (as unless by.nil?), ("top#{limit}" if by && limit) ]
42
+ (granularity if by), (as unless by.nil?), ("top#{limit}" if by && limit),
43
+ (sort if by && sort.present? && sort.to_s != "desc") ]
43
44
  parts.compact.join("_")
44
45
  end
45
46
 
46
- def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
47
+ def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, sort: nil, height: nil, prominence: nil, value_labels: nil, companions: nil, filters: {}, fixed: {}, default: {}, snapshot: nil, title: nil)
47
48
  @definition = definition
48
49
  @title = title
49
50
  @measure = measure
@@ -57,6 +58,10 @@ module Janela
57
58
  raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
58
59
  @granularity = Dimension.granularity!(granularity) if granularity.present?
59
60
  @limit = definition.limit!(limit) if limit.present?
61
+ # Unset and desc are the same pane, and stay the same id and the same
62
+ # snapshot key (ADR 055).
63
+ chosen = definition.sort!(sort)
64
+ @sort = chosen unless chosen == "desc"
60
65
  @height = height!(height) if height.present?
61
66
  @prominence = prominence!(prominence) if prominence.present?
62
67
  @value_labels = value_labels!(value_labels)
@@ -206,7 +211,7 @@ module Janela
206
211
 
207
212
  def turbo_frame_id
208
213
  self.class.turbo_frame_id(model: model, measure: measure, by: dimension, as: renderer,
209
- granularity: @granularity, limit: limit, snapshot: snapshot)
214
+ granularity: @granularity, limit: limit, sort: sort, snapshot: snapshot)
210
215
  end
211
216
 
212
217
  # A pane row may carry its own title, which is the first analyst authored
@@ -226,7 +231,7 @@ module Janela
226
231
  # What identifies this pane's data inside a snapshot.
227
232
  def lookup_key
228
233
  { "model" => model.model_name.route_key, "measure" => measure.to_s, "dimension" => dimension&.to_s,
229
- "granularity" => granularity&.to_s, "limit" => limit }
234
+ "granularity" => granularity&.to_s, "limit" => limit }.tap { |key| key["sort"] = sort if sort }
230
235
  end
231
236
 
232
237
  def result(on: nil)
@@ -238,7 +243,7 @@ module Janela
238
243
  # selection, which this pane shows the alternatives to (ADR 040, 043).
239
244
  on = definition.narrow(on || model.all, default) if default.present?
240
245
  on = definition.narrow(on || model.all, fixed) if fixed.present?
241
- primary = time? ? time_result(on) : definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
246
+ primary = time? ? time_result(on) : definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit, sort: sort)
242
247
  @companion_columns = companions? ? fetch_companions(primary, on) : []
243
248
  primary
244
249
  end
@@ -40,9 +40,9 @@ module Janela
40
40
  @panes = []
41
41
  end
42
42
 
43
- def pane(model, measure, by: nil, granularity: nil, limit: nil, on: nil)
43
+ def pane(model, measure, by: nil, granularity: nil, limit: nil, sort: nil, on: nil)
44
44
  query = Query.new(definition: model.janela, measure: measure, dimension: by,
45
- granularity: granularity, limit: limit, filters: filters)
45
+ granularity: granularity, limit: limit, sort: sort, filters: filters)
46
46
  panes << query.lookup_key.merge("result" => plain(query.result(on: on)))
47
47
  end
48
48
 
@@ -41,6 +41,13 @@
41
41
  <%= form.select :limit, Janela::Pane::OFFERED_LIMITS, include_blank: t("janela.panes.no_limit") %>
42
42
  </div>
43
43
 
44
+ <%# Blank is largest first, so desc is not offered a second time. %>
45
+ <div class="janela-field">
46
+ <%= form.label :sort %>
47
+ <%= form.select :sort, (Janela.sorts - %w[desc]).map { |value| [ t("janela.sorts.#{value}"), value ] }, include_blank: t("janela.sorts.desc") %>
48
+ <span class="janela-hint"><%= t("janela.sorts.hint") %></span>
49
+ </div>
50
+
44
51
  <%# What may sit beside the label: this model's other measures and its
45
52
  categorical dimensions. The pane's own choices are left out when they are
46
53
  already made (ADR 051). %>
@@ -19,6 +19,7 @@ en:
19
19
  renderer: Renderer
20
20
  granularity: Granularity
21
21
  limit: Rows
22
+ sort: Order
22
23
  span: Width
23
24
  height: Height
24
25
  prominence: Prominence
@@ -77,6 +78,12 @@ en:
77
78
  hint: Up to three, drawn as columns in a table. A dimension shows a value only where every row of its group shares one.
78
79
  value_labels:
79
80
  hint: Draws the value at the end of each bar of a bar chart, if every one fits. Other charts ignore it.
81
+ sorts:
82
+ desc: Largest first
83
+ asc: Smallest first
84
+ label: By label, A to Z
85
+ label_desc: By label, Z to A
86
+ hint: Applies to a breakdown by a category. A time series is always in time order.
80
87
  prominences:
81
88
  automatic: Automatic
82
89
  "1": Footnote
@@ -0,0 +1,7 @@
1
+ class AddSortToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # How a category pane is ordered (ADR 055): desc, asc, label or label_desc.
4
+ # Null is what every existing pane is: unset, and ordered exactly as before.
5
+ add_column :janela_panes, :sort, :string
6
+ end
7
+ end
@@ -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, 054 |
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, 054 |
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
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, 054 |
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 |
@@ -102,7 +102,8 @@ 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 |
105
106
 
106
107
  ## Next number
107
108
 
108
- Next ADR: 055
109
+ Next ADR: 056
data/docs/roadmap.md CHANGED
@@ -134,7 +134,8 @@ change in any release, and every change of that kind carries an entry in
134
134
  **A pane can be read.** Janela draws tables, bars, lines, doughnuts and
135
135
  pies. A table row can carry context beside its label, a single number says
136
136
  how prominent it is, a chart has a height that suits the page, its axis reads
137
- in the measure's own format, and a bar can show its value. Those
137
+ in the measure's own format, a bar can show its value, and a category pane can
138
+ be put in the order that reads. Those
138
139
  were not extra features. They were the 5% not finished, and most of them
139
140
  were found by people installing the gem rather than reading it.
140
141
 
@@ -151,9 +152,12 @@ height, a ratio measure, single value prominence, companion columns, Sprockets
151
152
  hosts, and how the project is run. 0.13.0 added tools an agent can use
152
153
  (ADR 053). 0.14.0 followed: a chart's axis reads in the measure's own format,
153
154
  so a ratio is a percentage down the side as well as in the tooltip, and a bar
154
- chart can show each bar's value (ADR 054). Both came from a real install, and
155
- the second adds a pane option, so it starts its own short soak. What 1.0 waits
156
- on now is a week of real installs of 0.14.0 ([#71](https://github.com/retail-tasker/janela/issues/71)),
155
+ chart can show each bar's value (ADR 054). 0.15.0 followed that: a category
156
+ pane can be sorted by its measure either way or by its label, so a bottom five
157
+ and a chart in date order can be drawn (ADR 055). 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)),
157
161
  since much of that surface was added in the last days and has only met this
158
162
  repository. When nothing surprising comes back, the surface is frozen and 1.0
159
163
  is tagged. The agent tools are the one new piece of surface: the read tools are
@@ -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 value_labels],
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,7 +17,7 @@ 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
20
+ PANE_ATTRIBUTES = %w[kind model measure dimension renderer granularity limit sort span title height prominence
21
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
@@ -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,6 +206,7 @@ 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 },
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.14.0"
4
+ VERSION = "0.15.0"
5
5
  end
data/lib/janela.rb CHANGED
@@ -170,6 +170,10 @@ module Janela
170
170
  # The steps a pane's height and prominence take, and how many companion
171
171
  # columns a table may carry, for a page that offers them as controls
172
172
  # (ADR 027, ADR 047, ADR 050, ADR 051).
173
+ def self.sorts
174
+ Definition::SORTS
175
+ end
176
+
173
177
  def self.heights
174
178
  Pane::HEIGHTS.to_a
175
179
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: janela
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.14.0
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jay Killeen
@@ -147,6 +147,7 @@ files:
147
147
  - db/migrate/20260930000002_add_prominence_to_janela_panes.rb
148
148
  - db/migrate/20260930000003_add_companions_to_janela_panes.rb
149
149
  - db/migrate/20261006000001_add_value_labels_to_janela_panes.rb
150
+ - db/migrate/20261013000001_add_sort_to_janela_panes.rb
150
151
  - docs/agents.md
151
152
  - docs/composing.md
152
153
  - docs/decisions/001-built-to-be-forked.md
@@ -203,6 +204,7 @@ files:
203
204
  - docs/decisions/052-how-the-project-is-run.md
204
205
  - docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md
205
206
  - docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md
207
+ - docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md
206
208
  - docs/decisions/INDEX.md
207
209
  - docs/multi-tenancy.md
208
210
  - docs/naming.md
@@ -229,17 +231,15 @@ metadata:
229
231
  bug_tracker_uri: https://github.com/retail-tasker/janela/issues
230
232
  rubygems_mfa_required: 'true'
231
233
  post_install_message: |
232
- Janela 0.14.0: one migration, if you use stored frames.
234
+ Janela 0.15.0: one migration, if you use stored frames.
233
235
 
234
- A pane can now draw each bar's value on a bar chart (ADR 054), which
235
- adds a nullable value_labels column to janela_panes. Run
236
+ A category pane can now be sorted by its measure either way, or by its
237
+ label (ADR 055), which adds a nullable sort column to janela_panes. Run
236
238
  bin/rails janela:install:migrations and then bin/rails db:migrate. If
237
239
  you never use stored frames there is nothing to do.
238
240
 
239
- Also: a chart's axis now reads in the measure's own format, so a
240
- currency chart gains its prefix and a ratio reads as percentages. A
241
- pane Janela will not draw is now that pane's own sentence and no
242
- longer an error for the whole host page.
241
+ Also: groups that tie on the measure now come out in label order, so
242
+ a pane with a limit shows the same rows every time.
243
243
 
244
244
  Steps: UPGRADING.md in this gem, or
245
245
  https://github.com/retail-tasker/janela/blob/main/UPGRADING.md