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
@@ -25,25 +25,35 @@
25
25
  "janela--chart-title-value": query.title,
26
26
  "janela--chart-selected-value": query.selected_values.to_json,
27
27
  "janela--chart-labels-value": result.keys.to_json,
28
- "janela--chart-values-value": result.values.map(&:to_f).to_json,
28
+ "janela--chart-values-value": result.values.map { |measured| measured&.to_f }.to_json,
29
29
  "janela--chart-formatted-value": result.values.map { |measured| query.format(measured) }.to_json,
30
30
  "janela--chart-tick-format-value": query.tick_format.to_json,
31
31
  "janela--chart-filters-value": query.filters_for(result.keys).to_json,
32
32
  "janela--chart-fixed-height-value": (true if query.boxed?),
33
- "janela--chart-value-labels-value": (true if query.labelled?)
33
+ "janela--chart-value-labels-value": (true if query.labelled?),
34
+ # Only with further series, so a chart without them is handed
35
+ # exactly what it always was (ADR 062).
36
+ "janela--chart-label-value": (query.series_label if query.series?),
37
+ "janela--chart-series-value": (query.chart_series(result).to_json if query.series?),
38
+ "janela--chart-axes-value": query.axis_titles&.to_json
34
39
  }) %>
35
40
  <%= query.boxed? ? tag.div(canvas, class: "janela-chart-box janela-h-#{query.height}") : canvas %>
36
41
  </figure>
37
42
  <% else %>
38
43
  <%= tag.table class: "janela-pane", aria: { description: (t("janela.time.click_hint") if query.time? && query.clickable?) } do %>
39
44
  <caption><%= query.title %></caption>
40
- <%# A header row only when there are companions to name: two columns read
41
- without one, and a table with none is what it always was (ADR 051). %>
42
- <% if query.companion_columns.any? %>
45
+ <%# A header row only when there are columns to name: two columns read
46
+ without one, and a table with none is what it always was (ADR 051).
47
+ A further series is a column after the measure's and before any
48
+ companion, which is also the table a chart falls back to (ADR 062). %>
49
+ <% if query.companion_columns.any? || query.drawn_series.any? %>
43
50
  <thead>
44
51
  <tr>
45
52
  <th scope="col"><%= query.dimension_header %></th>
46
53
  <th scope="col"><%= query.measure_header %></th>
54
+ <% query.drawn_series.each do |series| %>
55
+ <th scope="col"><%= query.series_label(series) %></th>
56
+ <% end %>
47
57
  <% query.companion_columns.each do |column| %>
48
58
  <%= tag.th column.header, scope: "col", class: ("janela-fact" if column.fact) %>
49
59
  <% end %>
@@ -63,6 +73,9 @@
63
73
  <% end %>
64
74
  </td>
65
75
  <td><%= query.format(measured) %></td>
76
+ <% query.drawn_series.each do |series| %>
77
+ <td><%= query.series_format(series, series.values[label]) %></td>
78
+ <% end %>
66
79
  <% query.companion_columns.each do |column| %>
67
80
  <%= tag.td column.cells[label], class: ("janela-fact" if column.fact) %>
68
81
  <% end %>
@@ -19,11 +19,15 @@ 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
25
26
  value_labels: Show each bar's value
26
27
  companions: Beside the label
28
+ cumulative: Running total
29
+ series: More series
30
+ ignore_unknown_filters: Ignore filters this model cannot apply
27
31
  title: Title
28
32
  janela:
29
33
  errors:
@@ -75,8 +79,29 @@ en:
75
79
  measures: Measures
76
80
  dimensions: Dimensions
77
81
  hint: Up to three, drawn as columns in a table. A dimension shows a value only where every row of its group shares one.
82
+ ignore_unknown_filters:
83
+ hint: For a pane beside panes over another model. A click there names a dimension this model does not have, and this pane stays as it is instead of saying the request is not allowed. A filter nobody's model declares is still a mistake on every other pane.
78
84
  value_labels:
79
- hint: Draws the value at the end of each bar of a bar chart, if every one fits. Other charts ignore it.
85
+ hint: Draws the value at the end of each bar of a bar chart, if every one fits, and a line's latest value at its end. A ring ignores it.
86
+ series:
87
+ to_date: "%{label} to date"
88
+ cumulative_hint: Adds the measure up across the buckets, so the pane reads as a total to date. Applies to a time dimension.
89
+ hint: Up to %{count} more measures over the same buckets, each a line or bars on the left axis or a right one. A table draws them as columns. A ring ignores them.
90
+ measure: Measure
91
+ by: Over
92
+ as: Drawn as
93
+ axis: Axis
94
+ none: None
95
+ same_as_pane: Same as the pane
96
+ axes:
97
+ left: Left
98
+ right: Right
99
+ sorts:
100
+ desc: Largest first
101
+ asc: Smallest first
102
+ label: By label, A to Z
103
+ label_desc: By label, Z to A
104
+ hint: Applies to a breakdown by a category. A time series is always in time order.
80
105
  prominences:
81
106
  automatic: Automatic
82
107
  "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,9 @@
1
+ class AddSeriesToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # A running total of a time pane's measure, and up to three more series
4
+ # drawn beside it (ADR 062). Null is what every existing pane is: unset,
5
+ # and drawn exactly as before.
6
+ add_column :janela_panes, :cumulative, :boolean
7
+ add_column :janela_panes, :series, :json
8
+ end
9
+ end
@@ -0,0 +1,8 @@
1
+ class AddIgnoreUnknownFiltersToJanelaPanes < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # Whether a pane drops a reader's filter its model cannot apply rather
4
+ # than refusing the request (ADR 063). Null is what every existing pane
5
+ # is: unset, and refused exactly as before.
6
+ add_column :janela_panes, :ignore_unknown_filters, :boolean
7
+ end
8
+ end
data/docs/agents.md CHANGED
@@ -15,6 +15,29 @@ them with whatever MCP library your application already runs. Janela
15
15
  registers nothing, detects nothing and serves nothing, because it cannot
16
16
  know who is asking and your application can.
17
17
 
18
+ ## Setting it up
19
+
20
+ ```
21
+ bin/rails generate janela:mcp
22
+ ```
23
+
24
+ This writes `bin/janela-mcp`, a stdio server built on the official Ruby SDK, and
25
+ adds a `janela` entry to `.mcp.json`, which Claude Code and other MCP clients
26
+ read to find it. Both are yours from then on. It leaves an existing server or
27
+ entry alone, and it does not touch your Gemfile: if `mcp` is not in your bundle
28
+ it says which line to add (`gem "mcp"`).
29
+
30
+ Open `bin/janela-mcp` and write the scope. It is the one thing the generator
31
+ cannot know: who the agent acts for, and what that caller may read. It cannot be
32
+ your controller's `policy_scope`, which needs a request, and called without one
33
+ it does not fail but answers a different question and reads the wrong rows
34
+ (ADR 057). Until you write it, every tool refuses to read and says so. The file
35
+ also refuses to run outside development, so an agent never reaches real data
36
+ until you decide it should.
37
+
38
+ The rest of this page explains what is in that file, for a host that would
39
+ rather write it by hand or register the tools with another library.
40
+
18
41
  ## Building the tools
19
42
 
20
43
  ```ruby
@@ -43,6 +66,7 @@ tool is called. They are cheap to construct.
43
66
  | `update_pane` | Change a pane. An invalid change leaves it as it was. | yes |
44
67
  | `remove_pane` | Remove a pane and close the gap. | yes |
45
68
  | `move_pane` | Move a pane one place up or down. | yes |
69
+ | `update_frame` | Change a frame's `columns` and `gap`. Its name, key and owner are not an agent's to change. | yes |
46
70
 
47
71
  The write tools are off unless you build the tools with `write: true`. Their
48
72
  inputs are the pane's own attributes, the ones the README
@@ -50,8 +74,8 @@ names, and nothing else: an attribute that is not a pane's is refused rather
50
74
  than dropped. A pane Janela would refuse is refused with its own reason, for
51
75
  example `Measure "nonsense" is not a measure of Order`.
52
76
 
53
- There is no tool to create a frame or take a snapshot. Your code finds or makes
54
- a frame with `Janela::Frame.for` (ADR 041), and a snapshot needs an owner and a
77
+ There is no tool to create a frame or take a snapshot, and none to change who owns
78
+ one. Your code finds or makes a frame with `Janela::Frame.for` (ADR 041), and a snapshot needs an owner and a
55
79
  scope you have named (ADR 033, ADR 034).
56
80
 
57
81
  ## Registering them
@@ -88,6 +112,13 @@ end
88
112
  `mcp` is not a dependency of Janela, and a host using another library writes the
89
113
  same few lines against it.
90
114
 
115
+ ## What an agent cannot reach
116
+
117
+ A server run with `bin/janela-mcp` reads and changes the database of the
118
+ environment it starts in, which is development. It cannot reach a dashboard on a
119
+ deployed host: that needs an authenticated path only your application can give,
120
+ because Janela serves no endpoint and does not know who is calling (ADR 053).
121
+
91
122
  ## What Janela does not do
92
123
 
93
124
  It does not serve MCP, detect a server, edit your configuration or install
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  Date: 2026-09-16
3
3
  Status: Accepted
4
+ Superseded in part by: ADR 059
4
5
  Related: ADR 009, ADR 011, ADR 013, ADR 016
5
6
  Triggers:
6
7
  - rendering a pane where the chart runtime may be absent
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  Date: 2026-10-02
3
3
  Status: Accepted
4
+ Superseded in part by: ADR 057
4
5
  Related: ADR 001, ADR 010, ADR 012, ADR 014, ADR 017, ADR 019, ADR 025, ADR 032, ADR 033, ADR 034, ADR 037, ADR 041
5
6
  Triggers:
6
7
  - proposing an MCP server, an agent tool or any machine facing surface for Janela
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  Date: 2026-10-06
3
- Status: Accepted
3
+ Status: Accepted (the sentence that a line ignores value_labels is superseded by ADR 062)
4
4
  Related: ADR 004, ADR 020, ADR 029, ADR 037, ADR 038, ADR 046, ADR 047, ADR 050, ADR 053
5
5
  Triggers:
6
6
  - a chart's axis showing a number the tooltip shows differently
@@ -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.
@@ -0,0 +1,51 @@
1
+ ---
2
+ Date: 2026-10-13
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 012, ADR 032, ADR 053
5
+ Triggers:
6
+ - changing what the demo records about a visitor
7
+ - adding a model to the demo that Janela reads
8
+ - arranging a demo frame by hand rather than through the tools
9
+ - the demo reset or its seeds
10
+ Topics: demo, dogfooding, privacy, agents, mcp, frames
11
+ ---
12
+
13
+ # ADR 056: The Demo Counts Its Own Visits, and an Agent Arranges the Dashboard
14
+
15
+ ## Context
16
+
17
+ The demo's data is a year of invented orders. A dashboard over real traffic
18
+ is a better proof that Janela works on rows nobody planted, and it is the
19
+ first thing here that an agent can arrange through `Janela::Tools` (ADR 053)
20
+ against a model that was not written for the tools. A real application had
21
+ just done the same for its own pages, with a `PageView` model, a concern and
22
+ a seeded frame, and that shape is small enough to copy unchanged.
23
+
24
+ ## Decision
25
+
26
+ **One `PageView` row per full HTML page opened.** `page` is
27
+ `controller#action`, `path` is the URL without its query string, and
28
+ `viewed_on` is a stored date. Nothing says who the visitor was: no cookie,
29
+ no address, no user agent. A Turbo Frame request (every pane), a prefetch,
30
+ a robot, a non-HTML response, a redirect and a non-GET are not page views,
31
+ and a failure to record is reported and never breaks the page.
32
+
33
+ **The frame is rows, and an agent arranges it.** `test/dummy/bin/janela-mcp`
34
+ registers the tools with the official SDK, writes enabled, scoped as the
35
+ demo's own `policy_scope` is, and refuses to run outside development. The
36
+ repository's `.mcp.json` points at it. The frame itself is made by the host,
37
+ since there is no `create_frame` tool, and the panes are added with
38
+ `add_pane`. The result is frozen as rows in `db/seeds.rb`, because the hourly
39
+ reset deletes every frame and seeds are what bring one back.
40
+
41
+ **`PageView` is not reset.** `demo:reset` puts the invented orders and the
42
+ frames back and leaves the visits alone, so the numbers stay real.
43
+
44
+ ## Consequences
45
+
46
+ - The dashboard starts empty on a fresh deploy and fills as people use the
47
+ site. That is honest, and is not seeded with invented history.
48
+ - Counting a visit costs one insert per page. Fine for a demo on SQLite, and
49
+ not a pattern to take to a busy host.
50
+ - A change to the dashboard is made through the tools and copied into the
51
+ seeds. If the two drift, the seeds win after the next reset.