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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +26 -8
- data/UPGRADING.md +59 -0
- data/app/assets/javascripts/janela/chart_controller.js +148 -42
- data/app/assets/stylesheets/janela.css +7 -0
- data/app/controllers/janela/panes_controller.rb +2 -2
- data/app/controllers/janela/queries_controller.rb +12 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +2 -0
- data/app/helpers/janela/frames_helper.rb +37 -9
- data/app/models/janela/pane.rb +33 -2
- data/app/models/janela/query.rb +252 -27
- data/app/models/janela/snapshot.rb +2 -2
- data/app/views/janela/frames/show.html.erb +2 -2
- data/app/views/janela/panes/_form.html.erb +53 -0
- data/app/views/janela/queries/_query.html.erb +18 -5
- data/config/locales/en.yml +26 -1
- data/db/migrate/20261013000001_add_sort_to_janela_panes.rb +7 -0
- data/db/migrate/20261029000001_add_series_to_janela_panes.rb +9 -0
- data/db/migrate/20261029000003_add_ignore_unknown_filters_to_janela_panes.rb +8 -0
- data/docs/agents.md +33 -2
- data/docs/decisions/018-a-table-is-the-universal-renderer.md +1 -0
- data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +1 -0
- data/docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md +1 -1
- data/docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md +204 -0
- data/docs/decisions/056-the-demo-counts-its-own-visits-and-an-agent-arranges-the-dashboard.md +51 -0
- data/docs/decisions/057-a-host-is-told-about-the-agent-tools-and-a-generator-sets-them-up.md +197 -0
- data/docs/decisions/058-the-deployed-demo-is-reached-over-ssh-and-its-visits-frame-survives-a-reset.md +94 -0
- data/docs/decisions/059-a-host-can-tell-janelas-own-pages-that-its-layout-draws-charts.md +91 -0
- data/docs/decisions/060-the-demo-tells-search-engines-what-it-is-and-shows-its-own-adoption.md +79 -0
- data/docs/decisions/061-the-demo-speaks-brazilian-portuguese-under-a-prefix-and-the-gem-stays-english.md +118 -0
- data/docs/decisions/062-a-time-pane-can-accumulate-and-carry-more-series-on-two-axes.md +282 -0
- data/docs/decisions/063-a-pane-can-ignore-a-filter-its-model-cannot-apply.md +147 -0
- data/docs/decisions/INDEX.md +25 -16
- data/docs/roadmap.md +10 -4
- data/lib/generators/janela/mcp/mcp_generator.rb +51 -0
- data/lib/generators/janela/mcp/templates/janela-mcp.tt +50 -0
- data/lib/janela/definition.rb +73 -4
- data/lib/janela/dimension.rb +9 -1
- data/lib/janela/doctor.rb +10 -1
- data/lib/janela/measure.rb +8 -0
- data/lib/janela/tools.rb +37 -4
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +12 -0
- 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
|
|
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
|
|
41
|
-
without one, and a table with none is what it always was (ADR 051).
|
|
42
|
-
|
|
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 %>
|
data/config/locales/en.yml
CHANGED
|
@@ -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.
|
|
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
|
|
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-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
|
data/docs/decisions/055-a-category-pane-can-be-sorted-by-its-measure-either-way-or-by-its-label.md
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-10-13
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 005, ADR 006, ADR 007, ADR 020, ADR 037, ADR 046, ADR 047, ADR 049, ADR 050, ADR 053, ADR 054
|
|
5
|
+
Triggers:
|
|
6
|
+
- adding a sort direction, or sorting a pane by something other than its measure
|
|
7
|
+
- asking for the bottom N, the lowest values, or a chart in label order
|
|
8
|
+
- a dimension whose labels are the natural order, such as a period code
|
|
9
|
+
- adding a parameter to the pane URL, or a column to janela_panes
|
|
10
|
+
- deciding where the NULL group sits in a pane's order
|
|
11
|
+
Topics: ordering, limit, panes, urls, query-layer, public-api, frames
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 055: A Category Pane Can Be Sorted by Its Measure Either Way, or by Its Label
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
ADR 007 ordered every category pane by its measure, largest first, with no
|
|
19
|
+
way to turn that off, and named what it left for later: "an 'other' bucket
|
|
20
|
+
that sums what the limit cut off, ascending order, sorting by label, and
|
|
21
|
+
paging. Each is plausible; none is needed by the first host." Two hosts
|
|
22
|
+
have now needed one each.
|
|
23
|
+
|
|
24
|
+
#75: a pane with a `limit` shows the top N and cannot show the bottom N, the
|
|
25
|
+
lowest scores rather than the highest. Reversing the result in the view
|
|
26
|
+
does not help, because the limit is applied in SQL before the host sees any
|
|
27
|
+
rows, so a reversed top ten is still the top ten. The host that raised it
|
|
28
|
+
carries a patch that gives each pane an ascending or descending order, and
|
|
29
|
+
that patch broke across releases because it copied method bodies.
|
|
30
|
+
|
|
31
|
+
#80: a chart of a measure by a dimension whose labels are the natural order,
|
|
32
|
+
such as `202603 March Grocery Independents`, where the leading `YYYYMM` makes
|
|
33
|
+
alphabetical order chronological, comes out largest first and not oldest
|
|
34
|
+
first, so a trend is unreadable. The host worked round it with a database
|
|
35
|
+
column that holds the month as a time dimension, because a time dimension is
|
|
36
|
+
the one exemption ADR 007 makes. That costs a column, and it merges any two
|
|
37
|
+
cycles that fall in the same month.
|
|
38
|
+
|
|
39
|
+
These are one decision. Both are "which end of which ordering does the pane
|
|
40
|
+
start from", they share every open question below, and building either alone
|
|
41
|
+
risks making the other's shape wrong.
|
|
42
|
+
|
|
43
|
+
### What was measured
|
|
44
|
+
|
|
45
|
+
Against the demo, on SQLite, before deciding.
|
|
46
|
+
|
|
47
|
+
**The ordering is one line.** `Definition#query` groups by the dimension and
|
|
48
|
+
orders by `measure.order_by` descending, then limits. Reversing the
|
|
49
|
+
direction and ordering by the dimension's own column both work with a
|
|
50
|
+
change to that line, and the limit follows the order: ascending by measure
|
|
51
|
+
with a limit of two returned the two smallest groups.
|
|
52
|
+
|
|
53
|
+
**A database sorts a column by the column's type.** Ordering in SQL puts a
|
|
54
|
+
numeric column in numeric order and a date column in date order, and sorts a
|
|
55
|
+
text column as text. A text label such as `202603 ...` sorts correctly
|
|
56
|
+
because its prefix is fixed width, and a text column holding `10` and `9`
|
|
57
|
+
sorts `10` before `9`, as any text sort does. The question #80 left open,
|
|
58
|
+
whether label sorting compares as text or follows the column's type, is
|
|
59
|
+
answered by letting the database do it: it follows the column's type,
|
|
60
|
+
because the column is what is being sorted.
|
|
61
|
+
|
|
62
|
+
**Text order is the database's collation, and it differs.** On SQLite
|
|
63
|
+
`B` sorts before `a`. Other databases sort case insensitively by default.
|
|
64
|
+
Janela does not read the collation and does not try to make them agree.
|
|
65
|
+
|
|
66
|
+
**The NULL group is placed differently by different databases.** SQLite sorts
|
|
67
|
+
NULL first in an ascending order. PostgreSQL, from its documentation and
|
|
68
|
+
not measured here, sorts it last. Left alone, a label ordered pane would
|
|
69
|
+
show `(none)` at the top on one host and at the bottom on another.
|
|
70
|
+
`ORDER BY column IS NULL, column` puts it last everywhere, and measured on
|
|
71
|
+
SQLite it did: `10, 202603 x, 9, B, a, b, (none)`.
|
|
72
|
+
|
|
73
|
+
**Ties have no order today.** Two groups with the same measure come out in
|
|
74
|
+
whatever order the database produces, and with a limit the choice between
|
|
75
|
+
them decides which one is shown at all. That is harmless for a top ten of
|
|
76
|
+
distinct sums and arbitrary for a bottom five of counts, where ties are the
|
|
77
|
+
norm.
|
|
78
|
+
|
|
79
|
+
### What was considered
|
|
80
|
+
|
|
81
|
+
**Order on the measure.** ADR 020 put formatting there, and the reasoning
|
|
82
|
+
does not carry over. Formatting is a fact about the number, true wherever it
|
|
83
|
+
is shown. Order is a choice about one view of it: the same revenue measure is
|
|
84
|
+
sensibly largest first in one pane, smallest first in another and in label
|
|
85
|
+
order in a third on the same page. A measure that carries an order would
|
|
86
|
+
force two measures for one number, which is the workaround ADR 002 offers
|
|
87
|
+
for variation and the wrong one here. The limit is already on the pane for
|
|
88
|
+
the same reason (ADR 007), and order and limit are read together.
|
|
89
|
+
|
|
90
|
+
**Two settings, a field and a direction** (`sort: :label, dir: :desc`). Two
|
|
91
|
+
columns and two URL parameters to say one of four things, with combinations
|
|
92
|
+
that mean nothing for a time pane. One setting with four values is the
|
|
93
|
+
vocabulary `height` and `prominence` already speak (ADR 047, ADR 050).
|
|
94
|
+
|
|
95
|
+
**Sorting in Ruby after the fetch.** Cannot work. The limit is applied in
|
|
96
|
+
SQL, so a Ruby sort of ten rows sorts the wrong ten.
|
|
97
|
+
|
|
98
|
+
**A natural or numeric aware text sort.** Not portable across databases and
|
|
99
|
+
a second query language to document. A host whose labels need it can sort by
|
|
100
|
+
a column that holds the sortable form, which is what the one host already
|
|
101
|
+
does, and which the label order now makes unnecessary for the common case.
|
|
102
|
+
|
|
103
|
+
**An "other" bucket and paging.** Still not needed by anyone. Left where
|
|
104
|
+
ADR 007 left them.
|
|
105
|
+
|
|
106
|
+
## Decision
|
|
107
|
+
|
|
108
|
+
**A category pane may carry a `sort`, one of four values, and unset is
|
|
109
|
+
exactly what a pane does today.**
|
|
110
|
+
|
|
111
|
+
| sort | order | reads as |
|
|
112
|
+
| --- | --- | --- |
|
|
113
|
+
| `desc` (default) | measure, largest first | top N |
|
|
114
|
+
| `asc` | measure, smallest first | bottom N |
|
|
115
|
+
| `label` | the dimension's own value, ascending | alphabetical, oldest first |
|
|
116
|
+
| `label_desc` | the dimension's own value, descending | newest first |
|
|
117
|
+
|
|
118
|
+
```erb
|
|
119
|
+
<%= janela_pane Order, :revenue, by: :customer, limit: 5, sort: :asc %>
|
|
120
|
+
<%= janela_pane Order, :revenue, by: :cycle, as: :bar, sort: :label %>
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**It is the pane's, in the three places the pane's other options are.**
|
|
124
|
+
|
|
125
|
+
- `Janela::Pane#sort`, a nullable string column validated to the four
|
|
126
|
+
values and offered as a select on the pane form (blank is "Largest
|
|
127
|
+
first").
|
|
128
|
+
- `janela_pane` and `janela_snapshot_pane` take `sort:`.
|
|
129
|
+
- The pane URL carries it as `?sort=asc`, so the server-rendered content
|
|
130
|
+
keeps it on every cross-filter and lazy load, the same reasoning as
|
|
131
|
+
`height` (ADR 047).
|
|
132
|
+
|
|
133
|
+
The column is named `sort` and not `order`, which is a reserved word in SQL
|
|
134
|
+
and the name of a relation method, and not `order_by`, which already means
|
|
135
|
+
the SQL expression on a measure.
|
|
136
|
+
|
|
137
|
+
**The database does the work, and the limit comes after.** For `label` and
|
|
138
|
+
`label_desc` the query orders by the dimension's column, so a number, a date
|
|
139
|
+
and a string each sort as themselves, and the limit then cuts that order:
|
|
140
|
+
`sort: :label, limit: 10` is the first ten labels. For `asc` it orders by the
|
|
141
|
+
measure ascending, so `limit: 5` is the bottom five.
|
|
142
|
+
|
|
143
|
+
**The `(none)` group is always last,** in all four orders, by ordering on
|
|
144
|
+
`column IS NULL` first. It is a group like any other for the measure orders,
|
|
145
|
+
and where its measure falls it falls. For the label orders it has no
|
|
146
|
+
position in the label's own order, so it goes to the end in a way that is
|
|
147
|
+
the same on every database.
|
|
148
|
+
|
|
149
|
+
**Ties are broken by the label, ascending,** in every order, so the same
|
|
150
|
+
data always draws the same pane and a limit between tied groups always
|
|
151
|
+
shows the same one. Unset panes were arbitrary in ties and are now fixed,
|
|
152
|
+
which changes nothing anyone could have relied on.
|
|
153
|
+
|
|
154
|
+
**A time pane ignores `sort`.** A time series is ordered by time and shows
|
|
155
|
+
its whole range (ADR 007), and that stands. A `sort` on one is ignored and
|
|
156
|
+
not an error, as a height is on a ring, so a row whose dimension is changed
|
|
157
|
+
to a time dimension is not made invalid and keeps its value if it is changed
|
|
158
|
+
back. Newest first on a time pane is a different question, left open below.
|
|
159
|
+
|
|
160
|
+
**A ring and a table take it unchanged.** Both draw the result in order: a
|
|
161
|
+
ring's slices and its legend follow it, a table's rows follow it. Nothing
|
|
162
|
+
special is built for either. A ring of the bottom five by `asc` is the caller's
|
|
163
|
+
choice, as a ring of the top five already is, and ADR 046's remark that a
|
|
164
|
+
ring suits few slices stands.
|
|
165
|
+
|
|
166
|
+
**It is part of the pane's identity when set,** the way `limit` is (ADR 007),
|
|
167
|
+
because "top five" and "bottom five" of one breakdown are two different
|
|
168
|
+
answers and may share a page. It is added to `Query.turbo_frame_id` only when
|
|
169
|
+
it is not the default, so every existing frame id is unchanged. This differs
|
|
170
|
+
from `height`, which is drawing and not identity (ADR 047).
|
|
171
|
+
|
|
172
|
+
## Consequences
|
|
173
|
+
|
|
174
|
+
- **A migration for stored frames.** `janela_panes` gains a nullable `sort`,
|
|
175
|
+
additive, so every existing pane is unset and unchanged. A host that uses
|
|
176
|
+
stored frames runs `bin/rails janela:install:migrations` and `db:migrate`,
|
|
177
|
+
written in `UPGRADING.md` and the post install message (ADR 015). The
|
|
178
|
+
doctor's `unmigrated-columns` check (ADR 035) is taught the column.
|
|
179
|
+
- **`Janela::Tools` has to learn the attribute,** in the same change as the
|
|
180
|
+
column, or an agent could arrange every part of a pane except how it is
|
|
181
|
+
ordered (ADR 053). Its vocabulary names the four values.
|
|
182
|
+
- **The pane URL grammar grows by one optional parameter,** which ADR 037
|
|
183
|
+
counts as public surface and is why this belongs before 1.0. ADR 005's
|
|
184
|
+
naming table gains a row: *bottom N* is `?sort=asc&limit=N`.
|
|
185
|
+
- **Snapshots record it.** A snapshot's lookup key includes the sort, as it
|
|
186
|
+
includes the limit, so a frozen "bottom five" is not found for a request
|
|
187
|
+
for "top five" (ADR 009).
|
|
188
|
+
- **Text labels sort by the host database's collation.** Documented, not
|
|
189
|
+
smoothed over. A host that needs a different text order sorts on a column
|
|
190
|
+
that holds it.
|
|
191
|
+
- Companion columns follow the primary measure's order and limit, as they do
|
|
192
|
+
today (ADR 051).
|
|
193
|
+
- The demo's playground gets a control for it, shown only for a category
|
|
194
|
+
pane, with the declaration rewriting itself as for the others (ADR 027).
|
|
195
|
+
- A pane ordered by label no longer puts the largest value first, so a chart
|
|
196
|
+
in label order can bury the biggest bar. That is the analyst's choice and
|
|
197
|
+
the reason for asking.
|
|
198
|
+
- Not decided here: newest first on a time pane; an "other" bucket; paging;
|
|
199
|
+
a host's own ordering by an arbitrary column. Each starts from this
|
|
200
|
+
table.
|
|
201
|
+
- What would change this decision: a need for an ordering by something that
|
|
202
|
+
is neither the measure nor the label, such as a second measure, which is an
|
|
203
|
+
argument for companion columns driving an order (ADR 051) and not for more
|
|
204
|
+
values here; or a database where `column IS NULL` is not portable.
|
|
@@ -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.
|