janela 0.2.0 → 0.2.1

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: 8f1be37d3c8dc019195214b51f66d263d13032c3f12e44861620fd2d4f1cbe88
4
- data.tar.gz: be62c3edcf0f95b897c22a7b348b76168398ee6f3c801230236a3195bda9aae6
3
+ metadata.gz: 86e2bcbf47fdcf475b8e34b2b1897d308a6c9f478e7b3f0903979cd3fa6bb8e2
4
+ data.tar.gz: 9116c50f2f6e92a69489827acf9e9d580954c21bbe068887c3605b96d0531019
5
5
  SHA512:
6
- metadata.gz: 63ef09cab8969ea61002ac8273304222ef40d3d11cc5d5470eecab7a26ef57d84186afb44b58b6bcbeb25a336ae16e91fbc45289199a2163ed3a203bef739ff8
7
- data.tar.gz: 05462c455475cadb4dc633e76ea3181eeba0d169c9f82090a715a39afb375b92adebba3ca4de4db9e41c03d6a3dd53a641017ee5605e3c90280c18ab3c5dba1a
6
+ metadata.gz: a132498d5553317cd888139ba00559b5c7844c3878bdfe4c0c595783e14e3e4a082ec6fbe0a844d9f07a985a586daf4a4104164ff9dd452ed8ddf4390bdfe630
7
+ data.tar.gz: 5f35f6d28fe84c469e3502f97f8c0ec13b7e1c703f04c5aa21360ef7ad6e14b4cc9c8e8d174e5ac1af9207adcc221d3ed430e2851059ef41f08eb5833637712b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-09-15
11
+
12
+ Fixes found by dogfooding 0.2.0 in a second application. 0.2.0 is unusable in any host whose layout contains a route helper, which is most of them.
13
+
14
+ ### Fixed
15
+
16
+ - Panes rendered in the host's application layout, so any route helper in it raised `NameError` inside the isolated engine and every pane 500'd. A pane in a Turbo Frame now carries no layout; opened directly it uses Janela's own minimal layout (ADR 011, #19).
17
+ - `average:` or `sum:` over a boolean column returned `true` instead of a ratio, because ActiveRecord casts an aggregate back through the column's type. Declaring one now raises and points at `dimension` instead (#20).
18
+ - A group whose dimension is null rendered as a blank label and filtered on an empty string. It is now labelled `(none)` and toggles Ransack's null predicate (#21).
19
+ - The README claimed time buckets follow `Time.zone`; on SQLite they are UTC, which silently shifts daily buckets by the host's offset (#22).
20
+
21
+ ### Changed
22
+
23
+ - A request for a model, measure, dimension or stored pane that does not exist is a 404; a renderer, granularity, limit or filter the request may not use is a 400. The response is a plain sentence, inside the requesting Turbo Frame when there is one, and the detail goes to the log instead of the client. `Janela::NotFound` and `Janela::BadRequest` subclass `Janela::Error`.
24
+
10
25
  ## [0.2.0] - 2026-09-15
11
26
 
12
27
  Breaking. Renames and a new URL scheme while the only installation is the author's own (ADR 005).
@@ -47,5 +62,6 @@ First alpha, installed from GitHub for testing in a single host application.
47
62
  - Only models that declare a `janela` block are addressable over HTTP.
48
63
  - ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
49
64
 
65
+ [0.2.1]: https://github.com/retail-tasker/janela/releases/tag/v0.2.1
50
66
  [0.2.0]: https://github.com/retail-tasker/janela/releases/tag/v0.2.0
51
67
  [0.1.0]: https://github.com/retail-tasker/janela/releases/tag/v0.1.0
data/README.md CHANGED
@@ -84,7 +84,7 @@ class Order < ApplicationRecord
84
84
  end
85
85
  ```
86
86
 
87
- A dimension with a `granularity` is a time dimension. Groupdate buckets it (`hour`, `day`, `week`, `month`, `quarter`, `year`), fills empty buckets with zero, and uses your app's `Time.zone` and week start.
87
+ A dimension with a `granularity` is a time dimension. Groupdate buckets it (`hour`, `day`, `week`, `month`, `quarter`, `year`), fills empty buckets with zero, and uses your app's `Time.zone` and week start. **On SQLite, buckets are UTC**, because SQLite cannot convert time zones: with a non-UTC `Time.zone` a daily bucket is shifted by your offset, and an early-morning row lands in the previous day. Coarser granularities blunt the shift without removing it. If you need local-day buckets on SQLite, store a local date column and use it as a plain dimension.
88
88
 
89
89
  Then query them:
90
90
 
@@ -135,7 +135,7 @@ Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one r
135
135
 
136
136
  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, 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.
137
137
 
138
- The dashboard's filters live in the page URL as the same `q[...]` parameters, so a reload keeps them and a filtered dashboard is a link you can send: `/reports/orders?q[status_eq]=paid` renders filtered before any JavaScript runs. A pane ignores filters on its own dimension, so clicking a value re-scopes the rest of the dashboard rather than collapsing the pane you clicked. Time panes re-scope with the others but are not click sources yet; drill-down is the next decision. The selected value is marked `aria-pressed="true"` on tables and drawn solid against faded siblings on charts, so it can be styled and read. A pane with no matching rows renders a `.janela-empty` paragraph. Only models that declare a `janela` block can be requested over HTTP.
138
+ The dashboard's filters live in the page URL as the same `q[...]` parameters, so a reload keeps them and a filtered dashboard is a link you can send: `/reports/orders?q[status_eq]=paid` renders filtered before any JavaScript runs. A pane ignores filters on its own dimension, so clicking a value re-scopes the rest of the dashboard rather than collapsing the pane you clicked. Time panes re-scope with the others but are not click sources yet; drill-down is the next decision. The selected value is marked `aria-pressed="true"` on tables and drawn solid against faded siblings on charts, so it can be styled and read. A pane with no matching rows renders a `.janela-empty` paragraph. A group whose dimension is null is labelled `(none)` and filters with Ransack's null predicate rather than an empty string. Only models that declare a `janela` block can be requested over HTTP.
139
139
 
140
140
  ### Pane URLs
141
141
 
@@ -175,6 +175,8 @@ Render a stored pane the same way you render a live one:
175
175
  <%= janela_snapshot_pane @snapshot, Order, :revenue, by: :status, as: :bar %>
176
176
  ```
177
177
 
178
+ Panes render in Janela's own minimal layout when opened directly, so a shared pane link shows its numbers but no host styling and no charts; set `Janela::ApplicationController.layout "application"` in an initializer to use your own layout, which must not call a bare host route helper (inside an engine those need a `main_app.` prefix). Inside a dashboard, panes are Turbo Frames and carry no layout at all.
179
+
178
180
  Stored panes are static by nature: no filter buttons, charts ignore clicks, and the URL says *as of*: `/dashboards/snapshots/42/orders/revenue/status`. Request filters are ignored because the snapshot's were fixed when it was taken.
179
181
 
180
182
  `Janela::SnapshotJob.perform_later(name:, panes: [{ "model" => "orders", "measure" => "revenue", "by" => "status" }])` takes one from serialisable arguments so you can schedule it with whatever runs your jobs. The job uses each model's default scope; if you scope by tenant, write your own job around `Snapshot.take` and pass `on:`.
@@ -212,7 +214,7 @@ Deliberately out of scope: report designer UI, natural-language query, a separat
212
214
 
213
215
  ## Status
214
216
 
215
- **v0.2.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering, bar and line charts, pane URLs, shareable dashboard URLs and snapshots work and are covered by unit and real-browser tests. Not yet built: drill-down on time panes, other chart types. Open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
217
+ **v0.2.1 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering, bar and line charts, pane URLs, shareable dashboard URLs and snapshots work and are covered by unit and real-browser tests. Not yet built: drill-down on time panes, other chart types. Open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
216
218
 
217
219
  ## Development
218
220
 
@@ -8,7 +8,7 @@ Chart.register(...registerables)
8
8
  // the difference. Turbo replaces the frame on every cross-filter, so the chart
9
9
  // is destroyed on disconnect and rebuilt on connect.
10
10
  export default class extends Controller {
11
- static values = { type: String, labels: Array, values: Array, key: String, title: String, selected: String }
11
+ static values = { type: String, labels: Array, values: Array, filters: Object, title: String, selected: String }
12
12
 
13
13
  connect() {
14
14
  this.chart = new Chart(this.element, {
@@ -27,9 +27,10 @@ export default class extends Controller {
27
27
  scales: { y: { beginAtZero: true } },
28
28
  plugins: { legend: { display: false } },
29
29
  onClick: (_event, elements) => {
30
- if (!this.keyValue || elements.length === 0) return
31
- const value = this.labelsValue[elements[0].index]
32
- this.dispatch("toggle", { detail: { key: this.keyValue, value } })
30
+ if (elements.length === 0) return
31
+ const label = this.labelsValue[elements[0].index]
32
+ const [key, value] = this.filtersValue[String(label)] || []
33
+ if (key) this.dispatch("toggle", { detail: { key, value } })
33
34
  }
34
35
  }
35
36
  })
@@ -1,5 +1,13 @@
1
1
  module Janela
2
2
  class ApplicationController < Janela.parent_controller.constantize
3
+ # A frame request needs no layout, since Turbo keeps only the matching
4
+ # frame. A direct request gets Janela's own minimal layout, because a host
5
+ # layout's route helpers cannot resolve inside an isolated engine (ADR 011).
6
+ layout -> { turbo_frame_request? ? false : "janela/application" }
7
+
8
+ rescue_from Janela::NotFound, with: :janela_not_found
9
+ rescue_from Janela::BadRequest, with: :janela_bad_request
10
+
3
11
  private
4
12
  # Pundit defines policy_scope on the host's ApplicationController, which
5
13
  # this inherits from, so authorisation applies without Janela depending
@@ -7,5 +15,24 @@ module Janela
7
15
  def janela_scope(model)
8
16
  respond_to?(:policy_scope, true) ? policy_scope(model) : model.all
9
17
  end
18
+
19
+ def janela_not_found(error)
20
+ janela_error(error, :not_found, "There is no such pane.")
21
+ end
22
+
23
+ def janela_bad_request(error)
24
+ janela_error(error, :bad_request, "That request is not allowed on this pane.")
25
+ end
26
+
27
+ # The detail names models and filter keys, so it goes to the log; the
28
+ # client sees a plain sentence. A Turbo Frame request gets its frame
29
+ # back so the dashboard shows the sentence where the pane would be.
30
+ def janela_error(error, status, message)
31
+ logger.warn("Janela: #{error.message}")
32
+ body = view_context.tag.p(message, class: "janela-pane janela-error")
33
+ frame = request.headers["Turbo-Frame"]
34
+ body = view_context.turbo_frame_tag(frame) { body } if frame.present?
35
+ render html: body, status: status, layout: frame.blank?
36
+ end
10
37
  end
11
38
  end
@@ -25,7 +25,7 @@ module Janela
25
25
  @filters = filters
26
26
  @snapshot = snapshot
27
27
 
28
- raise Error, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
28
+ raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
29
29
  @granularity = Dimension.granularity!(granularity) if granularity.present?
30
30
  @limit = definition.limit!(limit) if limit.present?
31
31
  end
@@ -88,19 +88,38 @@ module Janela
88
88
  definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
89
89
  end
90
90
 
91
- def filter_key
92
- "#{ransack_name}_eq" if clickable?
91
+ # The Ransack key and value a click on this label should toggle. A null
92
+ # group filters with the null predicate, not an empty string (ADR 009 has
93
+ # no say here; see issue #21).
94
+ def filter_params(label)
95
+ return [ nil, nil ] unless clickable?
96
+ return [ "#{ransack_name}_null", "1" ] if label.to_s == Dimension::NONE
97
+
98
+ [ "#{ransack_name}_eq", label.to_s ]
99
+ end
100
+
101
+ # Every label in this pane paired with the filter it toggles, for a chart
102
+ # to look up by label when a bar is clicked.
103
+ def filters_for(labels)
104
+ return {} unless clickable?
105
+
106
+ labels.to_h { |label| [ label.to_s, filter_params(label) ] }
93
107
  end
94
108
 
95
109
  # The filter on this pane's own dimension is not applied to its query, but
96
110
  # it is what the user clicked here, so the view highlights it.
97
111
  def selected_value
98
112
  return unless clickable?
113
+ return Dimension::NONE if filter("#{ransack_name}_null").present?
99
114
 
100
- filters[filter_key] || filters[filter_key.to_sym]
115
+ filter("#{ransack_name}_eq")
101
116
  end
102
117
 
103
118
  private
119
+ def filter(key)
120
+ filters[key] || filters[key.to_sym]
121
+ end
122
+
104
123
  def dimension_definition
105
124
  definition.dimension!(dimension)
106
125
  end
@@ -18,7 +18,7 @@ module Janela
18
18
  def stored_result(pane)
19
19
  key = pane.lookup_key
20
20
  entry = panes.find { |stored| stored.slice(*key.keys) == key }
21
- raise Error, "snapshot #{id} has no pane #{key.compact.values.join(' ')}" unless entry
21
+ raise NotFound, "snapshot #{id} has no pane #{key.compact.values.join(' ')}" unless entry
22
22
 
23
23
  entry["result"]
24
24
  end
@@ -1,3 +1,4 @@
1
+ <% content_for :title, @pane.title %>
1
2
  <%= turbo_frame_tag @pane.frame_id do %>
2
3
  <% if @pane.single_value? %>
3
4
  <p class="janela-pane janela-value">
@@ -12,28 +13,29 @@
12
13
  data-action="janela--chart:toggle->janela--dashboard#toggle"
13
14
  data-janela--chart-type-value="<%= @pane.renderer %>"
14
15
  data-janela--chart-title-value="<%= @pane.title %>"
15
- data-janela--chart-key-value="<%= @pane.filter_key %>"
16
16
  data-janela--chart-selected-value="<%= @pane.selected_value %>"
17
17
  data-janela--chart-labels-value="<%= @result.keys.to_json %>"
18
18
  data-janela--chart-values-value="<%= @result.values.map(&:to_f).to_json %>"
19
+ data-janela--chart-filters-value="<%= @pane.filters_for(@result.keys).to_json %>"
19
20
  role="img" aria-label="<%= @pane.title %>"></canvas>
20
21
  <% else %>
21
22
  <table class="janela-pane">
22
23
  <caption><%= @pane.title %></caption>
23
24
  <tbody>
24
- <% @result.each do |value, measured| %>
25
+ <% @result.each do |label, measured| %>
26
+ <% key, value = @pane.filter_params(label) %>
25
27
  <tr>
26
28
  <td>
27
- <% if @pane.clickable? %>
29
+ <% if key %>
28
30
  <button type="button"
29
- aria-pressed="<%= value.to_s == @pane.selected_value.to_s && @pane.selected_value.present? %>"
31
+ aria-pressed="<%= label.to_s == @pane.selected_value.to_s && @pane.selected_value.present? %>"
30
32
  data-action="janela--dashboard#toggle"
31
- data-janela--dashboard-key-param="<%= @pane.filter_key %>"
33
+ data-janela--dashboard-key-param="<%= key %>"
32
34
  data-janela--dashboard-value-param="<%= value %>">
33
- <%= value %>
35
+ <%= label %>
34
36
  </button>
35
37
  <% else %>
36
- <span><%= value %></span>
38
+ <span><%= label %></span>
37
39
  <% end %>
38
40
  </td>
39
41
  <td><%= number_with_delimiter(measured) %></td>
@@ -0,0 +1,18 @@
1
+ <%# A pane opened directly, outside a Turbo Frame. Deliberately minimal: the
2
+ engine cannot know the host's asset names or bundler, and a host layout's
3
+ route helpers do not resolve inside an isolated engine (ADR 011). A host
4
+ can override this file, or point the controller at its own layout. %>
5
+ <!DOCTYPE html>
6
+ <html>
7
+ <head>
8
+ <title><%= yield(:title).presence || "Janela" %></title>
9
+ <meta charset="utf-8">
10
+ <meta name="viewport" content="width=device-width,initial-scale=1">
11
+ <%= csrf_meta_tags %>
12
+ <%= csp_meta_tag %>
13
+ <%= yield :head %>
14
+ </head>
15
+ <body>
16
+ <%= yield %>
17
+ </body>
18
+ </html>
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  Date: 2026-09-15
3
3
  Status: Accepted
4
- Related: ADR 001, ADR 002, ADR 003, ADR 004
4
+ Related: ADR 001, ADR 002, ADR 003, ADR 004, ADR 011
5
5
  Triggers:
6
6
  - changing the URL of a pane or how a dashboard frame addresses one
7
7
  - mounting the engine anywhere other than the default, or with as:
@@ -134,3 +134,6 @@ author reached for unprompted.
134
134
  - Looking up the mount in the host's route table means the helper
135
135
  finds the first mount of the engine. Mounting Janela twice is
136
136
  unsupported and not a goal.
137
+ - The claim above that a directly opened pane renders "inside the
138
+ host's layout" was wrong and is superseded by ADR 011: a host
139
+ layout's route helpers cannot resolve inside an isolated engine.
@@ -0,0 +1,130 @@
1
+ ---
2
+ Date: 2026-09-15
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 005, ADR 009
5
+ Triggers:
6
+ - writing or changing guidance for AI agents about using Janela
7
+ - proposing that Janela ship an agent, a skill or an MCP surface to hosts
8
+ - adding generators or introspection tasks
9
+ - changing the public API in a way the guidance describes
10
+ - deciding what a host application receives on install beyond the code
11
+ Topics: ai, agents, jan, skills, documentation, install, dx, scope
12
+ ---
13
+
14
+ # ADR 010: Agent Guidance Ships, the Agent Waits
15
+
16
+ ## Context
17
+
18
+ ADR 001 decided the reasoning behind Janela ships inside the gem, as
19
+ ADRs, so a developer or their agent understands why a piece exists
20
+ before changing it. That covers the *why*. Nothing covers the *how*,
21
+ and the first real installation showed the gap has a price: three
22
+ traps bit within an hour, each a one line fix found only by debugging.
23
+ Ransack's allowlist is per class, so a `through:` dimension needs the
24
+ associated model to allow the attribute. A host that bundles with
25
+ esbuild has no importmap, so none of Janela's JavaScript loads and
26
+ nothing cross filters while everything looks right. Janela's
27
+ controllers are exactly as authenticated as the host's
28
+ `ApplicationController`, which in a host that authenticates per
29
+ controller means not at all.
30
+
31
+ None of that is derivable from reading the code, which is precisely
32
+ the test ADR 001 sets for what belongs in the repository as prose.
33
+
34
+ A second, larger idea came up alongside it: that Janela ship an agent
35
+ of its own, named Jan, so installing the gem gives a host a briefed
36
+ colleague rather than a briefing. Open source has always shipped code
37
+ and documentation; it could now ship the person who knows how to use
38
+ them. It is a genuinely novel idea and nobody is doing it.
39
+
40
+ The two are not the same commitment, and this ADR separates them.
41
+
42
+ ## Decision
43
+
44
+ **A skill ships with the gem and installs into the host.**
45
+ `docs/skills/janela/SKILL.md` lives in the gem's `docs/` tree beside
46
+ the ADRs. `rails janela:install:skill` copies it to the host's
47
+ `.claude/skills/janela/` and prints the one line to add to an
48
+ `AGENTS.md` or `CLAUDE.md` for tools that read those instead. Janela
49
+ never edits a host's instruction files itself.
50
+
51
+ The skill is knowledge, not instructions to a particular agent, which
52
+ is why it survives whatever agent formats come and go.
53
+
54
+ **What the skill covers, in this order:**
55
+
56
+ 1. The dashboard shape that works, and why: a row of single value
57
+ panes, a time series, then categorical breakdowns.
58
+ 2. Choosing measures and dimensions. Name them for what they mean and
59
+ alias through dimensions. Keep categories low cardinality or pass
60
+ `limit`. Declare time dimensions with the granularity people
61
+ actually ask about. A dimension declaration is a promise that
62
+ filtering on it is allowed.
63
+ 3. The naming table from ADR 005, so an analyst's sentence becomes a
64
+ pane URL and back.
65
+ 4. When to take a snapshot, and that a snapshot holds results, not
66
+ HTML.
67
+ 5. The three traps above, each with its one line fix.
68
+ 6. What Janela deliberately does not do, so an agent does not build a
69
+ report designer, natural language query, row level security or a
70
+ scheduler into the host by accident.
71
+
72
+ **The skill describes the README's API and nothing else.** One API,
73
+ one set of names. If the skill needs to say something the README does
74
+ not, the README is incomplete and gets fixed first. No agent only
75
+ vocabulary.
76
+
77
+ **The demo application is the worked example.** `test/dummy` shows
78
+ every construct on realistic data, deployed and clickable. The skill
79
+ points at specific files rather than duplicating them.
80
+
81
+ **Every code sample is executable.** Each sample in the skill and in
82
+ `docs/guides/` is lifted from a file the test suite exercises, or is
83
+ run by a test. A sample that cannot be run is written as a sentence,
84
+ not a code block. This is the mechanism that stops the guidance
85
+ drifting from the code.
86
+
87
+ **Jan is not shipped to hosts yet.** An agent definition is added to
88
+ this repository only, as the project's own collaborator, and is not
89
+ copied into host applications by any install task. Three reasons:
90
+
91
+ - Its method, read the models, declare dimensions, compose in the
92
+ standard shape, wire the install, verify in a browser, is what a
93
+ capable agent with the skill loaded already does. The marginal value
94
+ over the skill is a name to invoke and a guarantee the skill is
95
+ loaded. That is convenience, not capability, and convenience is not
96
+ worth a public surface.
97
+ - Agent definition formats are unsettled. The skill's content is prose
98
+ about dashboards and survives any format; an agent definition is the
99
+ part most likely to be stale in six months.
100
+ - A gem that writes a named agent into a host arrives with opinions
101
+ about how that team works, not only about what its code does. That
102
+ cuts against the posture in ADR 001, where the invitation is to read
103
+ the code and fork it.
104
+
105
+ **What would change this.** Ship Jan to hosts when either is true:
106
+
107
+ - Jan, used on this repository, demonstrably does something a skill
108
+ loaded agent does not. Name the thing in the ADR that supersedes
109
+ this one.
110
+ - An MCP surface exists. An agent that can query dashboards and take
111
+ snapshots through tools has a job no skill can do, and at that point
112
+ a named agent stops being a wrapper and becomes a user.
113
+
114
+ ## Consequences
115
+
116
+ - A host that installs Janela and runs one task gets an opinionated,
117
+ current briefing, including the three traps that cost real debugging
118
+ time. That is the concrete value of this layer and it lands now.
119
+ - `docs/guides/` becomes a real directory shipping in the gem, so a
120
+ forker receives reasoning, rules and how to together.
121
+ - Jan exists but only here. The agent that helps build the gem is the
122
+ agent that would one day help hosts, so drift between what Jan says
123
+ and what the code does surfaces in this repository first. That is
124
+ the trial period, and it is free.
125
+ - Saying no to shipping Jan is recorded rather than remembered, with
126
+ the conditions that would reverse it. A future proposal cites this
127
+ ADR instead of relitigating the idea.
128
+ - Introspection (`rails janela:describe Model`), generators and MCP
129
+ are each one ADR away. Each must be a wrapper over the existing API,
130
+ for the same reason the skill must describe only one.
@@ -0,0 +1,81 @@
1
+ ---
2
+ Date: 2026-09-15
3
+ Status: Accepted
4
+ Related: ADR 003, ADR 005
5
+ Triggers:
6
+ - changing which layout a pane renders in
7
+ - a host reporting NameError from its own layout when a pane loads
8
+ - adding anything to the engine's own layout
9
+ - making the dummy application unrepresentative of a real host
10
+ Topics: layouts, engines, panes, urls, install
11
+ ---
12
+
13
+ # ADR 011: Panes Do Not Render in the Host Layout
14
+
15
+ ## Context
16
+
17
+ ADR 005 promised that a pane opened directly "gets that pane alone
18
+ inside the host's layout". Dogfooding 0.2.0 in a second application
19
+ showed that promise is a bug. `Janela::ApplicationController` inherits
20
+ the host's `ApplicationController` and therefore the host's layout,
21
+ but the engine is `isolate_namespace`d, so a route helper written in
22
+ that layout resolves against Janela's routes and raises `NameError`.
23
+ Almost every real application layout has a nav, so almost every host
24
+ is broken on install, and a whole dashboard of frames goes blank.
25
+
26
+ The gem's own dummy application has no route helper in its layout,
27
+ which is exactly why the suite, the demo and the first host install
28
+ never saw it. The first host squeaked through because its only layout
29
+ helpers were an Active Storage URL and a PWA manifest path rather
30
+ than nav links.
31
+
32
+ ## Decision
33
+
34
+ **A pane requested inside a Turbo Frame renders with no layout.**
35
+ Turbo extracts the matching frame from the response and discards
36
+ everything around it, so a layout was never doing any work there. This
37
+ alone fixes every dashboard, which is how panes are almost always
38
+ requested.
39
+
40
+ **A pane requested directly renders in Janela's own layout.** A
41
+ minimal layout in the engine, carrying only a charset, a viewport, the
42
+ CSRF and CSP tags and a `yield`. It keeps ADR 005's shareable pane
43
+ alive and cannot depend on anything the host has not got.
44
+
45
+ ```ruby
46
+ layout -> { turbo_frame_request? ? false : "janela/application" }
47
+ ```
48
+
49
+ **A directly opened pane is therefore unstyled, and that is
50
+ documented.** Janela ships no CSS (issue #15), and the engine layout
51
+ deliberately loads none of the host's assets, because it cannot know
52
+ their names or whether the host bundles or uses importmap. A host that
53
+ wants its own styling on direct pane URLs sets
54
+ `Janela::ApplicationController.layout "application"` in an
55
+ initializer, and is told the condition: that layout must not call a
56
+ bare host route helper, since inside an engine those need a
57
+ `main_app.` prefix.
58
+
59
+ **The dummy application's layout gains a route helper.** The dummy is
60
+ the gem's stand in for a real host, and a stand in that omits the most
61
+ common thing a layout contains is not doing its job. A `link_to` to
62
+ the dashboard makes the suite fail if a pane ever renders in the host
63
+ layout again.
64
+
65
+ ## Consequences
66
+
67
+ - ADR 005's sentence about the host layout is superseded by this ADR.
68
+ The rest of ADR 005, the URL grammar and the naming table, stands.
69
+ - Direct pane URLs render a table but not a chart, because no
70
+ JavaScript is loaded. Shareable pane links are therefore honest for
71
+ data and plain for visuals until a host opts its own layout in.
72
+ Acceptable: the pane URL's job is to show a number to someone, and
73
+ the dashboard is where charts live.
74
+ - The engine now owns a view that a host might want to override.
75
+ `app/views/layouts/janela/application.html.erb` is overridable by
76
+ the usual Rails precedence, which is the Rails answer and needs no
77
+ configuration of Janela's own.
78
+ - The lesson generalises beyond layouts: the dummy application should
79
+ resemble a real host in the ways hosts actually vary. Each time a
80
+ host finds something the dummy could not, the dummy gains that
81
+ characteristic rather than the fix being verified only by hand.
@@ -20,16 +20,18 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
20
20
 
21
21
  | Topic | ADRs |
22
22
  |-------|------|
23
- | **Vision, scope, forkability** | 001 |
23
+ | **Vision, scope, forkability** | 001, 010 |
24
24
  | **Open-source & host-decoupling** | 001 |
25
25
  | **DSL & query layer** | 002, 006, 007 |
26
26
  | **Dependencies** | 002, 003, 004, 006 |
27
27
  | **Authorisation** | 002, 003, 004, 009 |
28
28
  | **Cross-filtering & Hotwire** | 003, 004, 005, 008 |
29
+ | **Layouts & views** | 011 |
29
30
  | **JavaScript delivery & charts** | 004, 006 |
30
31
  | **Time dimensions** | 006 |
31
- | **Routes, URLs & naming** | 005, 007, 008, 009 |
32
+ | **Routes, URLs & naming** | 005, 007, 008, 009, 011 |
32
33
  | **Snapshots & publishing** | 009 |
34
+ | **AI agents & guidance** | 010 |
33
35
  | **Security** | 003 |
34
36
  | **Testing** | 003 |
35
37
 
@@ -46,7 +48,9 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
46
48
  | 007 | Ordering and Limits | 2026-09-15 | Accepted |
47
49
  | 008 | Dashboard Filters in the Page URL | 2026-09-15 | Accepted |
48
50
  | 009 | Snapshots | 2026-09-15 | Accepted |
51
+ | 010 | Agent Guidance Ships, the Agent Waits | 2026-09-15 | Accepted |
52
+ | 011 | Panes Do Not Render in the Host Layout | 2026-09-15 | Accepted |
49
53
 
50
54
  ## Next number
51
55
 
52
- Next ADR: 010
56
+ Next ADR: 012
@@ -9,7 +9,7 @@ module Janela
9
9
  end
10
10
 
11
11
  def measure(name, **aggregate)
12
- measures[name] = Measure.build(name, **aggregate)
12
+ measures[name] = Measure.build(name, **aggregate).tap { |measure| reject_boolean_column!(measure) }
13
13
  end
14
14
 
15
15
  def dimension(name, through: nil, column: nil, granularity: nil)
@@ -36,17 +36,31 @@ module Janela
36
36
  else
37
37
  grouped = relation.group(dimension.attribute).order(Arel.sql("#{measure.sql_alias} DESC"))
38
38
  grouped = grouped.limit(limit!(limit)) if limit
39
- measure.apply(grouped)
39
+ measure.apply(grouped).transform_keys { |value| value.nil? ? Dimension::NONE : value }
40
40
  end
41
41
  end
42
42
 
43
43
  def dimension!(name)
44
- dimensions.fetch(name) { raise Error, "#{model} has no janela dimension #{name.inspect}" }
44
+ dimensions.fetch(name) { raise NotFound, "#{model} has no janela dimension #{name.inspect}" }
45
+ end
46
+
47
+ # ActiveRecord casts an aggregate back through the column's own type, so
48
+ # AVG over a boolean returns true rather than a ratio. Say so at
49
+ # declaration rather than rendering a meaningless pane.
50
+ def reject_boolean_column!(measure)
51
+ return unless measure.column && Measure::NUMERIC.include?(measure.aggregate)
52
+ return unless model.type_for_attribute(measure.column).type == :boolean
53
+
54
+ raise Error, "measure #{measure.name.inspect} takes #{measure.aggregate} of the boolean " \
55
+ "#{model}##{measure.column}, which ActiveRecord casts back to true or false. " \
56
+ "Declare dimension #{measure.column.inspect} instead and read the split."
57
+ rescue ActiveRecord::ActiveRecordError
58
+ nil # no database to ask yet; a query will raise on its own if it cannot run
45
59
  end
46
60
 
47
61
  def limit!(value)
48
62
  limit = Integer(value, exception: false)
49
- raise Error, "limit must be a whole number from 1 to 1000, got #{value.inspect}" unless limit&.between?(1, 1000)
63
+ raise BadRequest, "limit must be a whole number from 1 to 1000, got #{value.inspect}" unless limit&.between?(1, 1000)
50
64
  limit
51
65
  end
52
66
 
@@ -74,12 +88,12 @@ module Janela
74
88
  dropped = params.keys.reject { |key| applied.any? { |name| key.to_s.start_with?(name) } }
75
89
  return if dropped.empty?
76
90
 
77
- raise Error, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
91
+ raise BadRequest, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
78
92
  "Declare a janela dimension, or add it to ransackable_attributes."
79
93
  end
80
94
 
81
95
  def measure!(name)
82
- measures.fetch(name) { raise Error, "#{model} has no janela measure #{name.inspect}" }
96
+ measures.fetch(name) { raise NotFound, "#{model} has no janela measure #{name.inspect}" }
83
97
  end
84
98
  end
85
99
  end
@@ -2,6 +2,10 @@ module Janela
2
2
  class Dimension
3
3
  GRANULARITIES = %w[hour day week month quarter year].freeze
4
4
 
5
+ # A group of rows whose dimension is null. Labelled rather than blank, and
6
+ # filtered with Ransack's null predicate rather than an empty string.
7
+ NONE = "(none)".freeze
8
+
5
9
  LABELS = {
6
10
  "hour" => ->(t) { t.strftime("%Y-%m-%d %H:00") },
7
11
  "day" => ->(t) { t.strftime("%Y-%m-%d") },
@@ -29,7 +33,7 @@ module Janela
29
33
 
30
34
  def self.granularity!(value)
31
35
  value = value.to_s
32
- raise Error, "unknown granularity #{value.inspect}, use one of #{GRANULARITIES.join(', ')}" unless GRANULARITIES.include?(value)
36
+ raise BadRequest, "unknown granularity #{value.inspect}, use one of #{GRANULARITIES.join(', ')}" unless GRANULARITIES.include?(value)
33
37
  value
34
38
  end
35
39
 
@@ -1,6 +1,9 @@
1
1
  module Janela
2
2
  class Measure
3
3
  AGGREGATES = %i[sum count average minimum maximum].freeze
4
+ # Aggregates whose answer is a number, so a boolean column would have its
5
+ # result cast back to true or false by ActiveRecord.
6
+ NUMERIC = %i[sum average].freeze
4
7
 
5
8
  attr_reader :name, :aggregate, :column
6
9
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
data/lib/janela.rb CHANGED
@@ -12,6 +12,12 @@ require "janela/dimension"
12
12
 
13
13
  module Janela
14
14
  class Error < StandardError; end
15
+ # Something the request named does not exist: a model, measure, dimension
16
+ # or a pane a snapshot did not freeze. Rendered as 404.
17
+ class NotFound < Error; end
18
+ # Something the request asked for is not allowed here: a renderer, a
19
+ # granularity, a limit or a filter. Rendered as 400.
20
+ class BadRequest < Error; end
15
21
 
16
22
  # Janela's controllers inherit from the host's, so the host's authentication
17
23
  # and authorisation apply to dashboards with no configuration.
@@ -32,7 +38,7 @@ module Janela
32
38
  # In development a model is only registered once autoloaded, so a cold
33
39
  # lookup loads the app rather than constantizing an unvetted parameter.
34
40
  Rails.application.eager_load! unless registry.key?(route_key)
35
- class_name = registry.fetch(route_key) { raise Error, "#{route_key.inspect} is not a janela model" }
41
+ class_name = registry.fetch(route_key) { raise NotFound, "#{route_key.inspect} is not a janela model" }
36
42
 
37
43
  class_name.constantize.janela
38
44
  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.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jay Killeen
@@ -106,6 +106,7 @@ files:
106
106
  - app/models/janela/pane.rb
107
107
  - app/models/janela/snapshot.rb
108
108
  - app/views/janela/panes/show.html.erb
109
+ - app/views/layouts/janela/application.html.erb
109
110
  - config/importmap.rb
110
111
  - config/routes.rb
111
112
  - db/migrate/20260915000001_create_janela_snapshots.rb
@@ -118,6 +119,8 @@ files:
118
119
  - docs/decisions/007-ordering-and-limits.md
119
120
  - docs/decisions/008-dashboard-filters-in-the-page-url.md
120
121
  - docs/decisions/009-snapshots.md
122
+ - docs/decisions/010-agent-guidance-ships-the-agent-waits.md
123
+ - docs/decisions/011-panes-do-not-render-in-the-host-layout.md
121
124
  - docs/decisions/INDEX.md
122
125
  - lib/janela.rb
123
126
  - lib/janela/definition.rb