janela 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +33 -0
  3. data/README.md +26 -8
  4. data/UPGRADING.md +59 -0
  5. data/app/assets/javascripts/janela/chart_controller.js +148 -42
  6. data/app/assets/stylesheets/janela.css +7 -0
  7. data/app/controllers/janela/panes_controller.rb +2 -2
  8. data/app/controllers/janela/queries_controller.rb +12 -0
  9. data/app/controllers/janela/snapshot_queries_controller.rb +2 -0
  10. data/app/helpers/janela/frames_helper.rb +37 -9
  11. data/app/models/janela/pane.rb +33 -2
  12. data/app/models/janela/query.rb +252 -27
  13. data/app/models/janela/snapshot.rb +2 -2
  14. data/app/views/janela/frames/show.html.erb +2 -2
  15. data/app/views/janela/panes/_form.html.erb +53 -0
  16. data/app/views/janela/queries/_query.html.erb +18 -5
  17. data/config/locales/en.yml +26 -1
  18. data/db/migrate/20261013000001_add_sort_to_janela_panes.rb +7 -0
  19. data/db/migrate/20261029000001_add_series_to_janela_panes.rb +9 -0
  20. data/db/migrate/20261029000003_add_ignore_unknown_filters_to_janela_panes.rb +8 -0
  21. data/docs/agents.md +33 -2
  22. data/docs/decisions/018-a-table-is-the-universal-renderer.md +1 -0
  23. data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +1 -0
  24. data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +1 -1
  25. data/docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md +204 -0
  26. data/docs/decisions/056-the-demo-counts-its-own-visits-and-an-agent-arranges-the-dashboard.md +51 -0
  27. data/docs/decisions/057-a-host-is-told-about-the-agent-tools-and-a-generator-sets-them-up.md +197 -0
  28. data/docs/decisions/058-the-deployed-demo-is-reached-over-ssh-and-its-visits-frame-survives-a-reset.md +94 -0
  29. data/docs/decisions/059-a-host-can-tell-janelas-own-pages-that-its-layout-draws-charts.md +91 -0
  30. data/docs/decisions/060-the-demo-tells-search-engines-what-it-is-and-shows-its-own-adoption.md +79 -0
  31. data/docs/decisions/061-the-demo-speaks-brazilian-portuguese-under-a-prefix-and-the-gem-stays-english.md +118 -0
  32. data/docs/decisions/062-a-time-pane-can-accumulate-and-carry-more-series-on-two-axes.md +282 -0
  33. data/docs/decisions/063-a-pane-can-ignore-a-filter-its-model-cannot-apply.md +147 -0
  34. data/docs/decisions/INDEX.md +25 -16
  35. data/docs/roadmap.md +10 -4
  36. data/lib/generators/janela/mcp/mcp_generator.rb +51 -0
  37. data/lib/generators/janela/mcp/templates/janela-mcp.tt +50 -0
  38. data/lib/janela/definition.rb +73 -4
  39. data/lib/janela/dimension.rb +9 -1
  40. data/lib/janela/doctor.rb +10 -1
  41. data/lib/janela/measure.rb +8 -0
  42. data/lib/janela/tools.rb +37 -4
  43. data/lib/janela/version.rb +1 -1
  44. data/lib/janela.rb +12 -0
  45. metadata +22 -8
@@ -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)
@@ -138,6 +153,20 @@ module Janela
138
153
  dimensions.values.filter_map(&:through).map(&:to_s).uniq
139
154
  end
140
155
 
156
+ # The keys of these params Ransack would apply to this model, read by the
157
+ # same helper reject_dropped_filters! uses so the two cannot disagree about
158
+ # which keys are the model's. A pane that ignores unknown filters keeps
159
+ # these, and also a key on a dimension the model declares with a predicate
160
+ # Ransack does not know (path_inn), which is a misuse of a real key and is
161
+ # left for the filter to refuse (ADR 025, ADR 063). Only a key whose
162
+ # attribute the model does not declare at all is dropped.
163
+ def applicable(params)
164
+ return params if params.empty?
165
+
166
+ dropped = unapplied_keys(model.ransack(params.to_h), params)
167
+ params.reject { |key, _| dropped.include?(key) && !misspelt_declared_key?(key) }
168
+ end
169
+
141
170
  # A filter the host fixed for a render (ADR 040), applied as its own
142
171
  # condition before the reader's, so the reader's can only narrow inside
143
172
  # it. The same bounds as any filter: declared dimensions, ADR 025's
@@ -147,6 +176,24 @@ module Janela
147
176
  end
148
177
 
149
178
  private
179
+ # The label comes second in a measure order so that groups tied on the
180
+ # measure come out the same way every time, and a limit between them
181
+ # always shows the same one. IS NULL puts the (none) group last wherever
182
+ # the database would put it: SQLite sorts NULL first in an ascending
183
+ # order and PostgreSQL last, and a label order would otherwise differ by
184
+ # host (ADR 055). The database sorts the column as its own type, so a
185
+ # number is in numeric order and a date in date order.
186
+ def ordering(measure, dimension, sort)
187
+ label = dimension.quoted_column
188
+ by_label = "#{label} IS NULL, #{label}"
189
+ case sort
190
+ when "asc" then "#{measure.order_by} ASC, #{by_label} ASC"
191
+ when "label" then "#{by_label} ASC"
192
+ when "label_desc" then "#{by_label} DESC"
193
+ else "#{measure.order_by} DESC, #{by_label} ASC"
194
+ end
195
+ end
196
+
150
197
  def within_keys(relation, dimension, keys)
151
198
  labels = keys - [ Dimension::NONE ]
152
199
  condition = dimension.attribute.in(labels)
@@ -203,14 +250,36 @@ module Janela
203
250
  # Ransack silently discards conditions an allowlist does not permit,
204
251
  # which would quietly return unfiltered numbers.
205
252
  def reject_dropped_filters!(search, params)
206
- applied = search.conditions.flat_map { |condition| condition.attributes.map(&:name) }
207
- dropped = params.keys.reject { |key| applied.any? { |name| key.to_s.start_with?(name) } }
253
+ dropped = unapplied_keys(search, params)
208
254
  return if dropped.empty?
209
255
 
210
256
  raise BadRequest, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
211
257
  "Declare a janela dimension, or add it to ransackable_attributes."
212
258
  end
213
259
 
260
+ # The keys Ransack did not apply: those that are not exactly an attribute
261
+ # it applied followed by a predicate it knows. Exact, so status_code_in
262
+ # is not taken for status because it starts with it.
263
+ def unapplied_keys(search, params)
264
+ applied = search.conditions.flat_map { |condition| condition.attributes.map(&:name) }
265
+ params.keys.reject { |key| applied.any? { |name| predicate_of?(key, name) } }
266
+ end
267
+
268
+ def predicate_of?(key, name)
269
+ prefix = "#{name}_"
270
+ key.to_s.start_with?(prefix) && Ransack.predicates.has_key?(key.to_s.delete_prefix(prefix))
271
+ end
272
+
273
+ # A key Ransack did not apply that is a declared dimension and a suffix
274
+ # that is not another attribute with a known predicate on it: path_inn is
275
+ # path with a typo, status_code_in is a different attribute entirely.
276
+ def misspelt_declared_key?(key)
277
+ dimensions.values.reject(&:through).any? do |dimension|
278
+ rest = key.to_s.delete_prefix("#{dimension.ransack_name}_")
279
+ rest != key.to_s && Ransack.predicates.keys.none? { |predicate| rest.end_with?("_#{predicate}") }
280
+ end
281
+ end
282
+
214
283
  # An allowed attribute still reaches every predicate Ransack knows,
215
284
  # including _matches, an arbitrary LIKE pattern (ADR 025). A dashboard
216
285
  # asks in only the predicates its kind of dimension needs.
@@ -26,11 +26,19 @@ module Janela
26
26
  "hour" => ->(t) { t.strftime("%Y-%m-%d %H:00") },
27
27
  "day" => ->(t) { t.strftime("%Y-%m-%d") },
28
28
  "week" => ->(t) { t.strftime("%Y-%m-%d") },
29
- "month" => ->(t) { t.strftime("%b %Y") },
29
+ "month" => ->(t) { month_label(t) },
30
30
  "quarter" => ->(t) { "Q#{(t.month - 1) / 3 + 1} #{t.year}" },
31
31
  "year" => ->(t) { t.strftime("%Y") }
32
32
  }.freeze
33
33
 
34
+ # The month in the reader's language when the host has that language's month
35
+ # names, and in English when it has not: strftime is English only, and a host
36
+ # that never set a locale must see what it always did (ADR 061).
37
+ def self.month_label(time)
38
+ names = I18n.t("date.abbr_month_names", default: nil)
39
+ names.is_a?(Array) && names[time.month] ? "#{names[time.month]} #{time.year}" : time.strftime("%b %Y")
40
+ end
41
+
34
42
  attr_reader :name, :model, :through, :column, :granularity
35
43
 
36
44
  # A dimension is named for what it means on the dashboard and reads a
data/lib/janela/doctor.rb CHANGED
@@ -38,6 +38,7 @@ module Janela
38
38
  }.freeze
39
39
 
40
40
  SEARCHED = %w[app config lib].freeze
41
+ AGENT_SERVER = "bin/janela-mcp".freeze
41
42
  READABLE = %w[.rb .erb .js .erb.html .html.erb .haml .slim .yml].freeze
42
43
 
43
44
  def initialize(root)
@@ -66,6 +67,14 @@ module Janela
66
67
  # Said out loud every run, because a silence nobody remembers is how a
67
68
  # real finding goes unread.
68
69
  out.puts "Silenced: #{silenced.map(&:code).uniq.join(', ')}." if silenced.any?
70
+
71
+ # Not a finding: it is optional, so it is never counted, silenced or
72
+ # allowed to change the result. It says what it saw, which is a missing
73
+ # file, and is printed on a clean run too (ADR 057).
74
+ unless @root.join(AGENT_SERVER).exist?
75
+ out.puts
76
+ out.puts "Optional: let an agent arrange dashboards. bin/rails generate janela:mcp (docs/agents.md)."
77
+ end
69
78
  findings.none? { |finding| finding.severity == :error }
70
79
  end
71
80
 
@@ -148,7 +157,7 @@ module Janela
148
157
  # the same thing twice.
149
158
  ADDED_COLUMNS = {
150
159
  "Janela::Frame" => %w[key default_model default_where],
151
- "Janela::Pane" => %w[kind heading body link partial height prominence companions value_labels],
160
+ "Janela::Pane" => %w[kind heading body link partial height prominence companions value_labels sort cumulative series ignore_unknown_filters],
152
161
  "Janela::Snapshot" => %w[owner_type owner_id]
153
162
  }.freeze
154
163
 
@@ -46,6 +46,14 @@ module Janela
46
46
  aggregate == :ratio
47
47
  end
48
48
 
49
+ # A count or a sum adds up across buckets: a running total of it means
50
+ # something, and a bucket with no rows is zero. An average, a minimum, a
51
+ # maximum or a ratio does not, and a bucket with no rows has no value
52
+ # (ADR 062).
53
+ def additive?
54
+ %i[count sum].include?(aggregate)
55
+ end
56
+
49
57
  def apply(relation)
50
58
  return relation.average(ratio_expression) if ratio?
51
59
 
data/lib/janela/tools.rb CHANGED
@@ -17,11 +17,14 @@ 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
- value_labels 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 cumulative series ignore_unknown_filters heading body link partial].freeze
22
+ # A frame's grid is the only part of a frame an agent may change (ADR 057).
23
+ # Its name, key and owner are the host's to say (ADR 033, ADR 041).
24
+ FRAME_ATTRIBUTES = %w[columns gap].freeze
22
25
  DIRECTIONS = %w[up down].freeze
23
26
  READS = %w[describe_vocabulary list_frames get_frame read_pane].freeze
24
- WRITES = %w[add_pane update_pane remove_pane move_pane].freeze
27
+ WRITES = %w[add_pane update_pane remove_pane move_pane update_frame].freeze
25
28
 
26
29
  def initialize(scope: nil, write: false)
27
30
  unless scope.respond_to?(:call)
@@ -64,7 +67,8 @@ module Janela
64
67
  { models: Janela.definitions.map { |definition| model_hash(definition) },
65
68
  renderers: Janela.renderers.map(&:to_s),
66
69
  granularities: Janela.granularities.map(&:to_s),
67
- limits: Pane::OFFERED_LIMITS }
70
+ limits: Pane::OFFERED_LIMITS,
71
+ sorts: Janela.sorts }
68
72
  end
69
73
 
70
74
  def list_frames
@@ -88,6 +92,12 @@ module Janela
88
92
  reading.merge(values: result.transform_values(&:to_f), formatted: result.transform_values { |value| query.format(value) })
89
93
  end
90
94
 
95
+ def update_frame(frame_id:, **attributes)
96
+ frame = frame_in_scope(frame_id)
97
+ frame.update(frame_attributes(attributes)) || refuse(frame)
98
+ frame_hash(frame)
99
+ end
100
+
91
101
  def add_pane(frame_id:, **attributes)
92
102
  frame = frame_in_scope(frame_id)
93
103
  pane = frame.panes.build(pane_attributes(attributes))
@@ -143,6 +153,13 @@ module Janela
143
153
  attributes.transform_keys(&:to_s)
144
154
  end
145
155
 
156
+ def frame_attributes(attributes)
157
+ unknown = attributes.keys.map(&:to_s) - FRAME_ATTRIBUTES
158
+ raise BadRequest, "not a frame attribute an agent may change: #{unknown.join(', ')}. A frame's #{FRAME_ATTRIBUTES.join(' and ')} can be changed." if unknown.any?
159
+
160
+ attributes.transform_keys(&:to_s)
161
+ end
162
+
146
163
  def refuse(record)
147
164
  raise BadRequest, record.errors.full_messages.to_sentence
148
165
  end
@@ -186,6 +203,11 @@ module Janela
186
203
  "remove_pane" => tool("remove_pane", required: %w[pane_id],
187
204
  description: "Remove a pane and close the gap it leaves.",
188
205
  properties: { pane_id: { type: "integer" } }),
206
+ "update_frame" => tool("update_frame", required: %w[frame_id],
207
+ description: "Change a frame's grid: how many columns it has and the gap between panes. Nothing else of a frame can be changed.",
208
+ properties: { frame_id: { type: "integer" },
209
+ columns: { type: "integer", minimum: Frame::COLUMNS.min, maximum: Frame::COLUMNS.max },
210
+ gap: { type: "integer", minimum: Frame::GAPS.min, maximum: Frame::GAPS.max } }),
189
211
  "move_pane" => tool("move_pane", required: %w[pane_id direction],
190
212
  description: "Move a pane one place up or down its frame.",
191
213
  properties: { pane_id: { type: "integer" }, direction: { type: "string", enum: DIRECTIONS } })
@@ -205,12 +227,23 @@ module Janela
205
227
  renderer: { type: "string", enum: Janela.renderers.map(&:to_s) },
206
228
  granularity: { type: "string", enum: Janela.granularities.map(&:to_s) },
207
229
  limit: { type: "integer", minimum: Pane::LIMITS.min, maximum: Pane::LIMITS.max },
230
+ sort: { type: "string", enum: Janela.sorts },
208
231
  span: { type: "integer", minimum: Pane::SPANS.min, maximum: Pane::SPANS.max },
209
232
  title: { type: "string" },
210
233
  height: { type: "integer", enum: Pane::HEIGHTS.to_a },
211
234
  prominence: { type: "integer", enum: Pane::PROMINENCES.to_a },
212
235
  value_labels: { type: "boolean", description: "Draw each bar's value on a bar chart; other renderers ignore it" },
213
236
  companions: { type: "array", items: { type: "string" } },
237
+ cumulative: { type: "boolean", description: "A running total of the measure across a time pane's buckets" },
238
+ series: { type: "array", description: "Up to #{Query::MAX_SERIES} more series on a time pane, each a measure over a time dimension of the same model",
239
+ items: { type: "object", required: %w[measure], additionalProperties: false,
240
+ properties: { measure: { type: "string" },
241
+ by: { type: "string", description: "A time dimension; the pane's own when left out" },
242
+ cumulative: { type: "boolean" },
243
+ as: { type: "string", enum: Query::CANVAS },
244
+ axis: { type: "string", enum: Query::AXES },
245
+ title: { type: "string" } } } },
246
+ ignore_unknown_filters: { type: "boolean", description: "Drop the reader's filters this pane's model cannot apply rather than refusing them, for a pane beside panes over another model" },
214
247
  heading: { type: "string" },
215
248
  body: { type: "string" },
216
249
  link: { type: "string", description: "A path on this site, starting with /" },
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.14.0"
4
+ VERSION = "0.16.0"
5
5
  end
data/lib/janela.rb CHANGED
@@ -70,6 +70,14 @@ module Janela
70
70
  # whatever that host's layout says (ADR 023).
71
71
  mattr_accessor :theme, default: nil
72
72
 
73
+ # Whether the engine's own frame page draws a bar or line pane as a chart.
74
+ # False is a table, because the engine's minimal layout loads no chart
75
+ # runtime and cannot know whether a host's layout does (ADR 018). A host that
76
+ # has given Janela's own pages a layout that registers the Stimulus
77
+ # controllers and loads Chart.js says so here (ADR 059). A host that sets it
78
+ # without the runtime gets an empty hole where the table was.
79
+ mattr_accessor :charts_on_own_pages, default: false
80
+
73
81
  # Checks janela:doctor should not report, by the name it prints beside each
74
82
  # finding. A check that is a false alarm for one application stays a false
75
83
  # alarm, and that is a judgement made once at boot, which is what a setting
@@ -170,6 +178,10 @@ module Janela
170
178
  # The steps a pane's height and prominence take, and how many companion
171
179
  # columns a table may carry, for a page that offers them as controls
172
180
  # (ADR 027, ADR 047, ADR 050, ADR 051).
181
+ def self.sorts
182
+ Definition::SORTS
183
+ end
184
+
173
185
  def self.heights
174
186
  Pane::HEIGHTS.to_a
175
187
  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.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jay Killeen
@@ -147,6 +147,9 @@ 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
151
+ - db/migrate/20261029000001_add_series_to_janela_panes.rb
152
+ - db/migrate/20261029000003_add_ignore_unknown_filters_to_janela_panes.rb
150
153
  - docs/agents.md
151
154
  - docs/composing.md
152
155
  - docs/decisions/001-built-to-be-forked.md
@@ -203,11 +206,22 @@ files:
203
206
  - docs/decisions/052-how-the-project-is-run.md
204
207
  - docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md
205
208
  - docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md
209
+ - docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md
210
+ - docs/decisions/056-the-demo-counts-its-own-visits-and-an-agent-arranges-the-dashboard.md
211
+ - docs/decisions/057-a-host-is-told-about-the-agent-tools-and-a-generator-sets-them-up.md
212
+ - docs/decisions/058-the-deployed-demo-is-reached-over-ssh-and-its-visits-frame-survives-a-reset.md
213
+ - docs/decisions/059-a-host-can-tell-janelas-own-pages-that-its-layout-draws-charts.md
214
+ - docs/decisions/060-the-demo-tells-search-engines-what-it-is-and-shows-its-own-adoption.md
215
+ - docs/decisions/061-the-demo-speaks-brazilian-portuguese-under-a-prefix-and-the-gem-stays-english.md
216
+ - docs/decisions/062-a-time-pane-can-accumulate-and-carry-more-series-on-two-axes.md
217
+ - docs/decisions/063-a-pane-can-ignore-a-filter-its-model-cannot-apply.md
206
218
  - docs/decisions/INDEX.md
207
219
  - docs/multi-tenancy.md
208
220
  - docs/naming.md
209
221
  - docs/roadmap.md
210
222
  - docs/theming.md
223
+ - lib/generators/janela/mcp/mcp_generator.rb
224
+ - lib/generators/janela/mcp/templates/janela-mcp.tt
211
225
  - lib/janela.rb
212
226
  - lib/janela/definition.rb
213
227
  - lib/janela/dimension.rb
@@ -229,17 +243,17 @@ metadata:
229
243
  bug_tracker_uri: https://github.com/retail-tasker/janela/issues
230
244
  rubygems_mfa_required: 'true'
231
245
  post_install_message: |
232
- Janela 0.14.0: one migration, if you use stored frames.
246
+ Janela 0.16.0: one migration, if you use stored frames.
233
247
 
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
248
+ A time pane can now be a running total and carry more series on two
249
+ axes (ADR 062), and a pane can ignore a filter its model cannot apply
250
+ (ADR 063). That adds nullable cumulative, series and
251
+ ignore_unknown_filters columns to janela_panes. Run
236
252
  bin/rails janela:install:migrations and then bin/rails db:migrate. If
237
253
  you never use stored frames there is nothing to do.
238
254
 
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.
255
+ Also: a line chart with value_labels now writes its latest value at
256
+ its end.
243
257
 
244
258
  Steps: UPGRADING.md in this gem, or
245
259
  https://github.com/retail-tasker/janela/blob/main/UPGRADING.md