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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -0
  3. data/README.md +64 -11
  4. data/app/assets/javascripts/janela/chart_controller.js +12 -5
  5. data/app/assets/javascripts/janela/dashboard_controller.js +27 -9
  6. data/app/controllers/janela/application_controller.rb +27 -0
  7. data/app/controllers/janela/{visuals_controller.rb → panes_controller.rb} +6 -4
  8. data/app/controllers/janela/snapshot_panes_controller.rb +21 -0
  9. data/app/helpers/janela/dashboard_helper.rb +38 -5
  10. data/app/jobs/janela/snapshot_job.rb +18 -0
  11. data/app/models/janela/pane.rb +137 -0
  12. data/app/models/janela/snapshot.rb +51 -0
  13. data/app/views/janela/panes/show.html.erb +47 -0
  14. data/app/views/layouts/janela/application.html.erb +18 -0
  15. data/config/routes.rb +7 -1
  16. data/db/migrate/20260915000001_create_janela_snapshots.rb +13 -0
  17. data/docs/decisions/004-charts-and-javascript-delivery.md +5 -4
  18. data/docs/decisions/005-pane-urls-and-mount-path.md +139 -0
  19. data/docs/decisions/006-time-dimensions-with-groupdate.md +93 -0
  20. data/docs/decisions/007-ordering-and-limits.md +74 -0
  21. data/docs/decisions/008-dashboard-filters-in-the-page-url.md +68 -0
  22. data/docs/decisions/009-snapshots.md +141 -0
  23. data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +130 -0
  24. data/docs/decisions/011-panes-do-not-render-in-the-host-layout.md +81 -0
  25. data/docs/decisions/INDEX.md +19 -7
  26. data/lib/janela/definition.rb +44 -15
  27. data/lib/janela/dimension.rb +43 -4
  28. data/lib/janela/measure.rb +9 -0
  29. data/lib/janela/version.rb +1 -1
  30. data/lib/janela.rb +16 -10
  31. metadata +31 -5
  32. data/Rakefile +0 -23
  33. data/app/models/janela/visual.rb +0 -65
  34. 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.
@@ -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
- | **JavaScript delivery & charts** | 004 |
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: 005
56
+ Next ADR: 012
@@ -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
- def query(measure_name, by: nil, where: {}, on: nil)
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
- if by
26
- dimension = dimension!(by)
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
- measure!(measure_name).apply(relation)
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 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
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.name.to_s }
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 Error, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
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 Error, "#{model} has no janela measure #{name.inspect}" }
96
+ measures.fetch(name) { raise NotFound, "#{model} has no janela measure #{name.inspect}" }
68
97
  end
69
98
  end
70
99
  end
@@ -1,21 +1,60 @@
1
1
  module Janela
2
2
  class Dimension
3
- attr_reader :name, :model, :through
3
+ GRANULARITIES = %w[hour day week month quarter year].freeze
4
4
 
5
- def initialize(name, model:, through: nil)
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[name]
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}_#{name}" : name.to_s
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
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, so a
20
- # request parameter can never name an arbitrary class. Names are stored
21
- # rather than definitions so a reloaded model does not leave a stale class
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 ||= Set.new
30
+ @registry ||= {}
25
31
  end
26
32
 
27
33
  def self.register(model)
28
- registry << model.name
34
+ registry[model.model_name.route_key] = model.name
29
35
  end
30
36
 
31
- def self.definition!(name)
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.include?(name)
35
- raise Error, "#{name.inspect} is not a janela model" unless registry.include?(name)
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
- name.constantize.janela
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.0
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/visuals_controller.rb
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/models/janela/visual.rb
92
- - app/views/janela/visuals/show.html.erb
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]
@@ -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