janela 0.1.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 +41 -0
- data/README.md +64 -11
- data/app/assets/javascripts/janela/chart_controller.js +12 -5
- data/app/assets/javascripts/janela/dashboard_controller.js +27 -9
- data/app/controllers/janela/application_controller.rb +27 -0
- data/app/controllers/janela/{visuals_controller.rb → panes_controller.rb} +6 -4
- data/app/controllers/janela/snapshot_panes_controller.rb +21 -0
- data/app/helpers/janela/dashboard_helper.rb +38 -5
- data/app/jobs/janela/snapshot_job.rb +18 -0
- data/app/models/janela/pane.rb +137 -0
- data/app/models/janela/snapshot.rb +51 -0
- data/app/views/janela/panes/show.html.erb +47 -0
- data/app/views/layouts/janela/application.html.erb +18 -0
- data/config/routes.rb +7 -1
- data/db/migrate/20260915000001_create_janela_snapshots.rb +13 -0
- data/docs/decisions/004-charts-and-javascript-delivery.md +5 -4
- data/docs/decisions/005-pane-urls-and-mount-path.md +139 -0
- data/docs/decisions/006-time-dimensions-with-groupdate.md +93 -0
- data/docs/decisions/007-ordering-and-limits.md +74 -0
- data/docs/decisions/008-dashboard-filters-in-the-page-url.md +68 -0
- data/docs/decisions/009-snapshots.md +141 -0
- 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 +19 -7
- data/lib/janela/definition.rb +44 -15
- data/lib/janela/dimension.rb +43 -4
- data/lib/janela/measure.rb +9 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +16 -10
- metadata +31 -5
- data/Rakefile +0 -23
- data/app/models/janela/visual.rb +0 -65
- data/app/views/janela/visuals/show.html.erb +0 -36
|
@@ -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,13 +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
|
-
| **DSL & query layer** | 002 |
|
|
26
|
-
| **Dependencies** | 002, 003, 004 |
|
|
27
|
-
| **Authorisation** | 002, 003, 004 |
|
|
28
|
-
| **Cross-filtering & Hotwire** | 003, 004 |
|
|
29
|
-
| **
|
|
25
|
+
| **DSL & query layer** | 002, 006, 007 |
|
|
26
|
+
| **Dependencies** | 002, 003, 004, 006 |
|
|
27
|
+
| **Authorisation** | 002, 003, 004, 009 |
|
|
28
|
+
| **Cross-filtering & Hotwire** | 003, 004, 005, 008 |
|
|
29
|
+
| **Layouts & views** | 011 |
|
|
30
|
+
| **JavaScript delivery & charts** | 004, 006 |
|
|
31
|
+
| **Time dimensions** | 006 |
|
|
32
|
+
| **Routes, URLs & naming** | 005, 007, 008, 009, 011 |
|
|
33
|
+
| **Snapshots & publishing** | 009 |
|
|
34
|
+
| **AI agents & guidance** | 010 |
|
|
30
35
|
| **Security** | 003 |
|
|
31
36
|
| **Testing** | 003 |
|
|
32
37
|
|
|
@@ -38,7 +43,14 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
38
43
|
| 002 | Measures and Dimensions over Ransack | 2026-09-13 | Accepted |
|
|
39
44
|
| 003 | Cross-filtering with Turbo Frames | 2026-09-13 | Accepted |
|
|
40
45
|
| 004 | Charts and JavaScript Delivery | 2026-09-15 | Accepted |
|
|
46
|
+
| 005 | Pane URLs and the Mount Path | 2026-09-15 | Accepted |
|
|
47
|
+
| 006 | Time Dimensions with Groupdate | 2026-09-15 | Accepted |
|
|
48
|
+
| 007 | Ordering and Limits | 2026-09-15 | Accepted |
|
|
49
|
+
| 008 | Dashboard Filters in the Page URL | 2026-09-15 | Accepted |
|
|
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 |
|
|
41
53
|
|
|
42
54
|
## Next number
|
|
43
55
|
|
|
44
|
-
Next ADR:
|
|
56
|
+
Next ADR: 012
|
data/lib/janela/definition.rb
CHANGED
|
@@ -9,34 +9,63 @@ 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
|
-
def dimension(name, through: nil)
|
|
16
|
-
dimensions[name] = Dimension.new(name, model: model, through: through)
|
|
15
|
+
def dimension(name, through: nil, column: nil, granularity: nil)
|
|
16
|
+
dimensions[name] = Dimension.new(name, model: model, through: through, column: column, granularity: granularity)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
# Filters are Ransack params, so a host can pass params[:q] straight
|
|
20
20
|
# through from a search_form_for slicer. Scope with on: to respect the
|
|
21
|
-
# host's authorisation, e.g. on: policy_scope(Order).
|
|
22
|
-
|
|
21
|
+
# host's authorisation, e.g. on: policy_scope(Order). A time dimension
|
|
22
|
+
# buckets by its declared granularity unless one is given.
|
|
23
|
+
def query(measure_name, by: nil, where: {}, on: nil, granularity: nil, limit: nil)
|
|
24
|
+
measure = measure!(measure_name)
|
|
23
25
|
relation = filter(on || model.all, where)
|
|
26
|
+
return measure.apply(relation) if by.nil?
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
relation = relation.left_joins(dimension.through) if dimension.through
|
|
28
|
-
relation = relation.group(dimension.attribute)
|
|
29
|
-
end
|
|
28
|
+
dimension = dimension!(by)
|
|
29
|
+
relation = relation.left_joins(dimension.through) if dimension.through
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
if dimension.time?
|
|
32
|
+
granularity = Dimension.granularity!(granularity || dimension.granularity)
|
|
33
|
+
options = granularity == "week" ? { week_start: Date.beginning_of_week } : {}
|
|
34
|
+
buckets = relation.group_by_period(granularity, dimension.qualified_column, **options)
|
|
35
|
+
measure.apply(buckets).transform_keys { |bucket| dimension.label(bucket, granularity) }
|
|
36
|
+
else
|
|
37
|
+
grouped = relation.group(dimension.attribute).order(Arel.sql("#{measure.sql_alias} DESC"))
|
|
38
|
+
grouped = grouped.limit(limit!(limit)) if limit
|
|
39
|
+
measure.apply(grouped).transform_keys { |value| value.nil? ? Dimension::NONE : value }
|
|
40
|
+
end
|
|
32
41
|
end
|
|
33
42
|
|
|
34
43
|
def dimension!(name)
|
|
35
|
-
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
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def limit!(value)
|
|
62
|
+
limit = Integer(value, exception: false)
|
|
63
|
+
raise BadRequest, "limit must be a whole number from 1 to 1000, got #{value.inspect}" unless limit&.between?(1, 1000)
|
|
64
|
+
limit
|
|
36
65
|
end
|
|
37
66
|
|
|
38
67
|
def ransackable_attributes
|
|
39
|
-
dimensions.values.reject(&:through).map { |dimension| dimension.
|
|
68
|
+
dimensions.values.reject(&:through).map { |dimension| dimension.column.to_s }
|
|
40
69
|
end
|
|
41
70
|
|
|
42
71
|
def ransackable_associations
|
|
@@ -59,12 +88,12 @@ module Janela
|
|
|
59
88
|
dropped = params.keys.reject { |key| applied.any? { |name| key.to_s.start_with?(name) } }
|
|
60
89
|
return if dropped.empty?
|
|
61
90
|
|
|
62
|
-
raise
|
|
91
|
+
raise BadRequest, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
|
|
63
92
|
"Declare a janela dimension, or add it to ransackable_attributes."
|
|
64
93
|
end
|
|
65
94
|
|
|
66
95
|
def measure!(name)
|
|
67
|
-
measures.fetch(name) { raise
|
|
96
|
+
measures.fetch(name) { raise NotFound, "#{model} has no janela measure #{name.inspect}" }
|
|
68
97
|
end
|
|
69
98
|
end
|
|
70
99
|
end
|
data/lib/janela/dimension.rb
CHANGED
|
@@ -1,21 +1,60 @@
|
|
|
1
1
|
module Janela
|
|
2
2
|
class Dimension
|
|
3
|
-
|
|
3
|
+
GRANULARITIES = %w[hour day week month quarter year].freeze
|
|
4
4
|
|
|
5
|
-
|
|
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
|
+
|
|
9
|
+
LABELS = {
|
|
10
|
+
"hour" => ->(t) { t.strftime("%Y-%m-%d %H:00") },
|
|
11
|
+
"day" => ->(t) { t.strftime("%Y-%m-%d") },
|
|
12
|
+
"week" => ->(t) { t.strftime("%Y-%m-%d") },
|
|
13
|
+
"month" => ->(t) { t.strftime("%b %Y") },
|
|
14
|
+
"quarter" => ->(t) { "Q#{(t.month - 1) / 3 + 1} #{t.year}" },
|
|
15
|
+
"year" => ->(t) { t.strftime("%Y") }
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
attr_reader :name, :model, :through, :column, :granularity
|
|
19
|
+
|
|
20
|
+
# A dimension is named for what it means on the dashboard and reads a
|
|
21
|
+
# column that may be called something else, usually on an association:
|
|
22
|
+
# dimension :customer, through: :customer, column: :name.
|
|
23
|
+
def initialize(name, model:, through: nil, column: nil, granularity: nil)
|
|
6
24
|
@name = name
|
|
7
25
|
@model = model
|
|
8
26
|
@through = through
|
|
27
|
+
@column = (column || name).to_sym
|
|
28
|
+
@granularity = granularity&.to_s
|
|
9
29
|
|
|
10
30
|
raise Error, "#{model} has no association #{through.inspect}" if through && reflection.nil?
|
|
31
|
+
self.class.granularity!(@granularity) if @granularity
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.granularity!(value)
|
|
35
|
+
value = value.to_s
|
|
36
|
+
raise BadRequest, "unknown granularity #{value.inspect}, use one of #{GRANULARITIES.join(', ')}" unless GRANULARITIES.include?(value)
|
|
37
|
+
value
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def time?
|
|
41
|
+
!granularity.nil?
|
|
11
42
|
end
|
|
12
43
|
|
|
13
44
|
def attribute
|
|
14
|
-
klass.arel_table[
|
|
45
|
+
klass.arel_table[column]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def qualified_column
|
|
49
|
+
"#{klass.table_name}.#{column}"
|
|
15
50
|
end
|
|
16
51
|
|
|
17
52
|
def ransack_name
|
|
18
|
-
through ? "#{through}_#{
|
|
53
|
+
through ? "#{through}_#{column}" : column.to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def label(bucket, granularity = self.granularity)
|
|
57
|
+
LABELS.fetch(granularity).call(bucket)
|
|
19
58
|
end
|
|
20
59
|
|
|
21
60
|
private
|
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
|
|
|
@@ -22,5 +25,11 @@ module Janela
|
|
|
22
25
|
def apply(relation)
|
|
23
26
|
column ? relation.public_send(aggregate, column) : relation.public_send(aggregate)
|
|
24
27
|
end
|
|
28
|
+
|
|
29
|
+
# The column alias ActiveRecord gives a grouped calculation, so a
|
|
30
|
+
# relation can be ordered by the measure before it is calculated.
|
|
31
|
+
def sql_alias
|
|
32
|
+
"#{aggregate}_#{column || 'all'}"
|
|
33
|
+
end
|
|
25
34
|
end
|
|
26
35
|
end
|
data/lib/janela/version.rb
CHANGED
data/lib/janela.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "ransack"
|
|
2
|
+
require "groupdate"
|
|
2
3
|
require "turbo-rails"
|
|
3
4
|
require "stimulus-rails"
|
|
4
5
|
|
|
@@ -11,29 +12,34 @@ require "janela/dimension"
|
|
|
11
12
|
|
|
12
13
|
module Janela
|
|
13
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
|
|
14
21
|
|
|
15
22
|
# Janela's controllers inherit from the host's, so the host's authentication
|
|
16
23
|
# and authorisation apply to dashboards with no configuration.
|
|
17
24
|
mattr_accessor :parent_controller, default: "ApplicationController"
|
|
18
25
|
|
|
19
|
-
# Only models that declare a janela block are addressable over HTTP,
|
|
20
|
-
#
|
|
21
|
-
# rather than
|
|
22
|
-
# behind in development.
|
|
26
|
+
# Only models that declare a janela block are addressable over HTTP, keyed by
|
|
27
|
+
# the route key that appears in pane URLs (orders, sales_orders). Names are
|
|
28
|
+
# stored rather than classes so a reloaded model leaves nothing stale behind.
|
|
23
29
|
def self.registry
|
|
24
|
-
@registry ||=
|
|
30
|
+
@registry ||= {}
|
|
25
31
|
end
|
|
26
32
|
|
|
27
33
|
def self.register(model)
|
|
28
|
-
registry
|
|
34
|
+
registry[model.model_name.route_key] = model.name
|
|
29
35
|
end
|
|
30
36
|
|
|
31
|
-
def self.definition!(
|
|
37
|
+
def self.definition!(route_key)
|
|
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
|
-
Rails.application.eager_load! unless registry.
|
|
35
|
-
raise
|
|
40
|
+
Rails.application.eager_load! unless registry.key?(route_key)
|
|
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
|
|
39
45
|
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.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jay Killeen
|
|
@@ -38,6 +38,20 @@ dependencies:
|
|
|
38
38
|
- - ">="
|
|
39
39
|
- !ruby/object:Gem::Version
|
|
40
40
|
version: '4.0'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: groupdate
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '6.0'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - ">="
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '6.0'
|
|
41
55
|
- !ruby/object:Gem::Dependency
|
|
42
56
|
name: turbo-rails
|
|
43
57
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -81,21 +95,32 @@ files:
|
|
|
81
95
|
- CHANGELOG.md
|
|
82
96
|
- LICENSE.txt
|
|
83
97
|
- README.md
|
|
84
|
-
- Rakefile
|
|
85
98
|
- app/assets/javascripts/janela/chart_controller.js
|
|
86
99
|
- app/assets/javascripts/janela/dashboard_controller.js
|
|
87
100
|
- app/assets/javascripts/janela/vendor/chart.js
|
|
88
101
|
- app/controllers/janela/application_controller.rb
|
|
89
|
-
- app/controllers/janela/
|
|
102
|
+
- app/controllers/janela/panes_controller.rb
|
|
103
|
+
- app/controllers/janela/snapshot_panes_controller.rb
|
|
90
104
|
- app/helpers/janela/dashboard_helper.rb
|
|
91
|
-
- app/
|
|
92
|
-
- app/
|
|
105
|
+
- app/jobs/janela/snapshot_job.rb
|
|
106
|
+
- app/models/janela/pane.rb
|
|
107
|
+
- app/models/janela/snapshot.rb
|
|
108
|
+
- app/views/janela/panes/show.html.erb
|
|
109
|
+
- app/views/layouts/janela/application.html.erb
|
|
93
110
|
- config/importmap.rb
|
|
94
111
|
- config/routes.rb
|
|
112
|
+
- db/migrate/20260915000001_create_janela_snapshots.rb
|
|
95
113
|
- docs/decisions/001-built-to-be-forked.md
|
|
96
114
|
- docs/decisions/002-measures-and-dimensions-over-ransack.md
|
|
97
115
|
- docs/decisions/003-cross-filtering-with-turbo-frames.md
|
|
98
116
|
- docs/decisions/004-charts-and-javascript-delivery.md
|
|
117
|
+
- docs/decisions/005-pane-urls-and-mount-path.md
|
|
118
|
+
- docs/decisions/006-time-dimensions-with-groupdate.md
|
|
119
|
+
- docs/decisions/007-ordering-and-limits.md
|
|
120
|
+
- docs/decisions/008-dashboard-filters-in-the-page-url.md
|
|
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
|
|
99
124
|
- docs/decisions/INDEX.md
|
|
100
125
|
- lib/janela.rb
|
|
101
126
|
- lib/janela/definition.rb
|
|
@@ -112,6 +137,7 @@ metadata:
|
|
|
112
137
|
source_code_uri: https://github.com/retail-tasker/janela
|
|
113
138
|
changelog_uri: https://github.com/retail-tasker/janela/blob/main/CHANGELOG.md
|
|
114
139
|
bug_tracker_uri: https://github.com/retail-tasker/janela/issues
|
|
140
|
+
rubygems_mfa_required: 'true'
|
|
115
141
|
rdoc_options: []
|
|
116
142
|
require_paths:
|
|
117
143
|
- lib
|
data/Rakefile
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
require "bundler/setup"
|
|
2
|
-
|
|
3
|
-
APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
|
|
4
|
-
load "rails/tasks/engine.rake"
|
|
5
|
-
|
|
6
|
-
require "bundler/gem_tasks"
|
|
7
|
-
|
|
8
|
-
require "minitest/test_task"
|
|
9
|
-
|
|
10
|
-
Minitest::TestTask.create do |t|
|
|
11
|
-
t.test_globs = Dir["test/**/*_test.rb"] - Dir["test/system/**/*_test.rb"]
|
|
12
|
-
t.warning = false
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
Minitest::TestTask.create(:system) do |t|
|
|
16
|
-
t.test_globs = [ "test/system/**/*_test.rb" ]
|
|
17
|
-
t.warning = false
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
require "rubocop/rake_task"
|
|
21
|
-
RuboCop::RakeTask.new
|
|
22
|
-
|
|
23
|
-
task default: %i[test rubocop]
|
data/app/models/janela/visual.rb
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
module Janela
|
|
2
|
-
# One measure grouped by one dimension, rendered as a table or a chart. A
|
|
3
|
-
# visual ignores filters on its own dimension: clicking a value in a visual
|
|
4
|
-
# should re-scope the others, not collapse itself to the value clicked.
|
|
5
|
-
class Visual
|
|
6
|
-
RENDERERS = %w[table bar].freeze
|
|
7
|
-
|
|
8
|
-
attr_reader :definition, :measure, :dimension, :renderer, :filters
|
|
9
|
-
|
|
10
|
-
# The helper renders the frame and the controller renders its replacement,
|
|
11
|
-
# so both derive the id the same way from the same parameters.
|
|
12
|
-
def self.frame_id(model:, measure:, by:, as: :table)
|
|
13
|
-
"janela_#{model.to_s.underscore}_#{measure}_by_#{by}_#{as}"
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def initialize(definition:, measure:, dimension:, renderer: "table", filters: {})
|
|
17
|
-
@definition = definition
|
|
18
|
-
@measure = measure
|
|
19
|
-
@dimension = dimension
|
|
20
|
-
@renderer = renderer.to_s
|
|
21
|
-
@filters = filters
|
|
22
|
-
|
|
23
|
-
raise Error, "unknown visual renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def model
|
|
27
|
-
definition.model
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def chart?
|
|
31
|
-
renderer != "table"
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def frame_id
|
|
35
|
-
self.class.frame_id(model: model.name, measure: measure, by: dimension, as: renderer)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def title
|
|
39
|
-
"#{measure.to_s.humanize} by #{dimension.to_s.humanize}"
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def result(on: nil)
|
|
43
|
-
definition.query(measure, by: dimension, where: applicable_filters, on: on)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def filter_key
|
|
47
|
-
"#{ransack_name}_eq"
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# The filter on this visual's own dimension is not applied to its query,
|
|
51
|
-
# but it is what the user clicked here, so the view highlights it.
|
|
52
|
-
def selected_value
|
|
53
|
-
filters[filter_key] || filters[filter_key.to_sym]
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
private
|
|
57
|
-
def ransack_name
|
|
58
|
-
definition.dimension!(dimension).ransack_name
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def applicable_filters
|
|
62
|
-
filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|