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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +5 -3
- data/app/assets/javascripts/janela/chart_controller.js +5 -4
- data/app/controllers/janela/application_controller.rb +27 -0
- data/app/models/janela/pane.rb +23 -4
- data/app/models/janela/snapshot.rb +1 -1
- data/app/views/janela/panes/show.html.erb +9 -7
- data/app/views/layouts/janela/application.html.erb +18 -0
- data/docs/decisions/005-pane-urls-and-mount-path.md +4 -1
- data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +130 -0
- data/docs/decisions/011-panes-do-not-render-in-the-host-layout.md +81 -0
- data/docs/decisions/INDEX.md +7 -3
- data/lib/janela/definition.rb +20 -6
- data/lib/janela/dimension.rb +5 -1
- data/lib/janela/measure.rb +3 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +7 -1
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 86e2bcbf47fdcf475b8e34b2b1897d308a6c9f478e7b3f0903979cd3fa6bb8e2
|
|
4
|
+
data.tar.gz: 9116c50f2f6e92a69489827acf9e9d580954c21bbe068887c3605b96d0531019
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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,
|
|
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 (
|
|
31
|
-
const
|
|
32
|
-
|
|
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
|
data/app/models/janela/pane.rb
CHANGED
|
@@ -25,7 +25,7 @@ module Janela
|
|
|
25
25
|
@filters = filters
|
|
26
26
|
@snapshot = snapshot
|
|
27
27
|
|
|
28
|
-
raise
|
|
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
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
|
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 |
|
|
25
|
+
<% @result.each do |label, measured| %>
|
|
26
|
+
<% key, value = @pane.filter_params(label) %>
|
|
25
27
|
<tr>
|
|
26
28
|
<td>
|
|
27
|
-
<% if
|
|
29
|
+
<% if key %>
|
|
28
30
|
<button type="button"
|
|
29
|
-
aria-pressed="<%=
|
|
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="<%=
|
|
33
|
+
data-janela--dashboard-key-param="<%= key %>"
|
|
32
34
|
data-janela--dashboard-value-param="<%= value %>">
|
|
33
|
-
<%=
|
|
35
|
+
<%= label %>
|
|
34
36
|
</button>
|
|
35
37
|
<% else %>
|
|
36
|
-
<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.
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -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:
|
|
56
|
+
Next ADR: 012
|
data/lib/janela/definition.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
96
|
+
measures.fetch(name) { raise NotFound, "#{model} has no janela measure #{name.inspect}" }
|
|
83
97
|
end
|
|
84
98
|
end
|
|
85
99
|
end
|
data/lib/janela/dimension.rb
CHANGED
|
@@ -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
|
|
36
|
+
raise BadRequest, "unknown granularity #{value.inspect}, use one of #{GRANULARITIES.join(', ')}" unless GRANULARITIES.include?(value)
|
|
33
37
|
value
|
|
34
38
|
end
|
|
35
39
|
|
data/lib/janela/measure.rb
CHANGED
|
@@ -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
|
|
data/lib/janela/version.rb
CHANGED
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
|
|
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.
|
|
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
|