janela 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
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
@@ -0,0 +1,36 @@
1
+ <%= turbo_frame_tag @visual.frame_id do %>
2
+ <% if @result.empty? %>
3
+ <p class="janela-visual janela-empty"><%= @visual.title %>: no data</p>
4
+ <% elsif @visual.chart? %>
5
+ <canvas class="janela-visual janela-chart"
6
+ data-controller="janela--chart"
7
+ data-action="janela--chart:toggle->janela--dashboard#toggle"
8
+ data-janela--chart-type-value="<%= @visual.renderer %>"
9
+ data-janela--chart-title-value="<%= @visual.title %>"
10
+ data-janela--chart-key-value="<%= @visual.filter_key %>"
11
+ data-janela--chart-selected-value="<%= @visual.selected_value %>"
12
+ data-janela--chart-labels-value="<%= @result.keys.to_json %>"
13
+ data-janela--chart-values-value="<%= @result.values.map(&:to_f).to_json %>"
14
+ role="img" aria-label="<%= @visual.title %>"></canvas>
15
+ <% else %>
16
+ <table class="janela-visual">
17
+ <caption><%= @visual.title %></caption>
18
+ <tbody>
19
+ <% @result.each do |value, measured| %>
20
+ <tr>
21
+ <td>
22
+ <button type="button"
23
+ aria-pressed="<%= value.to_s == @visual.selected_value.to_s && @visual.selected_value.present? %>"
24
+ data-action="janela--dashboard#toggle"
25
+ data-janela--dashboard-key-param="<%= @visual.filter_key %>"
26
+ data-janela--dashboard-value-param="<%= value %>">
27
+ <%= value %>
28
+ </button>
29
+ </td>
30
+ <td><%= number_with_delimiter(measured) %></td>
31
+ </tr>
32
+ <% end %>
33
+ </tbody>
34
+ </table>
35
+ <% end %>
36
+ <% end %>
@@ -0,0 +1,5 @@
1
+ pin "janela/dashboard_controller", to: "janela/dashboard_controller.js"
2
+ pin "janela/chart_controller", to: "janela/chart_controller.js"
3
+
4
+ # Hosts that already pin their own Chart.js keep it.
5
+ pin "chart.js", to: "janela/vendor/chart.js" unless packages.key?("chart.js")
data/config/routes.rb ADDED
@@ -0,0 +1,3 @@
1
+ Janela::Engine.routes.draw do
2
+ resource :visual, only: :show
3
+ end
@@ -0,0 +1,73 @@
1
+ ---
2
+ Date: 2026-09-11
3
+ Status: Accepted
4
+ Triggers:
5
+ - adding a feature or configuration option to Janela
6
+ - a design decision that touches a specific host application's domain or business logic
7
+ - reviewing a contribution (human or agent-authored)
8
+ - deciding how much flexibility/configurability a new API surface should expose
9
+ Topics: vision, scope, forkability, open-source, host-decoupling
10
+ ---
11
+
12
+ # ADR 001: Built to Be Forked
13
+
14
+ ## Context
15
+
16
+ Janela is a standalone open-source Rails gem: business-intelligence-
17
+ style dashboards and cross-filtering on ActiveRecord models. It is
18
+ being built and open-sourced deliberately, with the aspiration of
19
+ presenting it to the wider Rails community.
20
+
21
+ Commercial BI dashboard tools' actual surface area is mostly enterprise
22
+ packaging. The load-bearing 5%, declarative measures and dimensions
23
+ over a data model plus cross-filtering, is the real gap in the Rails BI
24
+ ecosystem. Every other feature a BI tool "should" have (drag-drop
25
+ designer, NL query, RLS subsystem, embedding SDK, etc.) is either
26
+ bloat or something Rails/Pundit/ActiveJob already own better.
27
+
28
+ Separately, DHH's framing in his September 2026 Lex Fridman interview
29
+ (#501, "Future of Programming, AI, Agentic Engineering, Vibe Coding &
30
+ Linux", https://lexfridman.com/dhh-2-transcript/) gave language to an
31
+ approach already implicit in the scope decision: agents make it
32
+ economically viable for a consumer to fork a small tool and keep only
33
+ the 5% they need, rather than adopt a large configurable one wholesale.
34
+ He also argued that overly prescriptive project instructions actively
35
+ damage agent output. Describe the problem, not the solution.
36
+
37
+ ## Decision
38
+
39
+ Janela is designed to be forked, not just configured.
40
+
41
+ 1. **Ship only the load-bearing 5%.** Measures/dimensions as a thin
42
+ Ruby DSL over Ransack (not a new query language), cross-filtering
43
+ via a Stimulus controller + Turbo Frames (not a JS framework).
44
+ Everything else stays out of scope.
45
+ 2. **Prefer one obvious way to do a thing over configurable
46
+ flexibility.** Every added config flag is a fork someone didn't
47
+ need to make. Default to fewer knobs, not more.
48
+ 3. **Stay fully decoupled from any host application.** No
49
+ application-specific code, model names, table shapes, or business
50
+ logic in this repo, ever. Host applications depend on Janela;
51
+ Janela never depends on a host application.
52
+ 4. **Document decisions as ADRs in this repo**, so a forker
53
+ understands *why* a piece exists before they rip it out or
54
+ replace it, not just what it does.
55
+ 5. **Treat agent-authored contributions the same as human ones.**
56
+ Reviewed on the merits of the diff, not the source.
57
+ 6. **Keep project instructions (CLAUDE.md) minimal and intent-focused**
58
+ rather than prescriptive, consistent with the reasoning above.
59
+
60
+ ## Consequences
61
+
62
+ - Feature requests that only make sense as a config flag for one
63
+ user's edge case should default to "fork it" rather than "add a
64
+ flag." This will read as less accommodating than a typical OSS
65
+ project and is intentional.
66
+ - The codebase must stay small and legible enough that forking a
67
+ chunk is realistic. This is a constant pressure against adding
68
+ abstractions, even useful-seeming ones.
69
+ - Any specific host application's needs get solved in that
70
+ application's own codebase against Janela's public API, never by
71
+ special-casing Janela itself.
72
+ - Future scope decisions (what's in the 5%, what isn't) should cite
73
+ this ADR rather than re-litigate the philosophy each time.
@@ -0,0 +1,125 @@
1
+ ---
2
+ Date: 2026-09-13
3
+ Status: Accepted
4
+ Related: ADR 001
5
+ Triggers:
6
+ - adding or changing the measures/dimensions DSL
7
+ - deciding how a dashboard filter reaches the query layer
8
+ - adding a dependency to the query path
9
+ - wiring authorisation around a dashboard query
10
+ - adding time-granularity or drill-down dimensions
11
+ Topics: dsl, query-layer, ransack, dependencies, authorisation
12
+ ---
13
+
14
+ # ADR 002: Measures and Dimensions over Ransack
15
+
16
+ ## Context
17
+
18
+ ADR 001 committed to shipping the load-bearing core: declarative
19
+ measures/dimensions, and cross-filtering. This ADR covers the first
20
+ half, spiked against real ActiveRecord models in `test/dummy`.
21
+
22
+ Two questions had to be answered by building rather than arguing.
23
+
24
+ **What sits under the DSL?** The spike first hid Ransack behind
25
+ Janela's own filter vocabulary (`where: { region: "EU" }`), translating
26
+ dimension names into Ransack predicates. That worked, but the
27
+ familiarity a Rails developer gets from Ransack was spent entirely
28
+ inside Janela. The host still inherited Ransack's constraints while
29
+ touching none of its API. Plain ActiveRecord was the obvious
30
+ alternative, since every Janela filter is only ever "dimension in
31
+ values".
32
+
33
+ **How does authorisation get in?** ADR 001 already put a
34
+ row-level-security subsystem out of scope. The open question was
35
+ whether to depend on Pundit directly.
36
+
37
+ A survey of the filtering ecosystem informed the first question.
38
+ Ransack has roughly 115M downloads against 1.9M for the next
39
+ most-used option, so it is the only filter gem that a Rails developer
40
+ can be assumed to already know. Notably, none of the filter gems do
41
+ aggregation. The `GROUP BY` half of Janela is Janela's own code
42
+ regardless of what sits underneath the filter half.
43
+
44
+ ## Decision
45
+
46
+ **The DSL is a `janela` block on the model.**
47
+
48
+ ```ruby
49
+ class Order < ApplicationRecord
50
+ belongs_to :customer
51
+
52
+ janela do
53
+ measure :revenue, sum: :amount
54
+ measure :orders, count: true
55
+ dimension :status
56
+ dimension :region, through: :customer
57
+ end
58
+ end
59
+ ```
60
+
61
+ A measure takes exactly one aggregate of `sum`, `count`, `average`,
62
+ `minimum`, `maximum`. A dimension is a column on the model, or a
63
+ column on an association via `through:`.
64
+
65
+ **Filters are Ransack params, passed through rather than translated.**
66
+
67
+ ```ruby
68
+ Order.janela.query(:revenue, by: :status, where: { customer_region_in: %w[APAC EU] })
69
+ ```
70
+
71
+ The host can hand `params[:q]` from a standard `search_form_for`
72
+ slicer straight to Janela with no translation layer, and the
73
+ cross-filter controller emits predicate names every Rails developer
74
+ already reads fluently. Ransack is a runtime dependency.
75
+
76
+ **Dimensions define the Ransack allowlist.** Declaring a dimension is
77
+ declaring that the attribute is filterable, so Janela generates
78
+ `ransackable_attributes` and `ransackable_associations` on the model.
79
+ A model that already declares its own keeps it.
80
+
81
+ **A dropped filter raises.** Ransack silently discards conditions its
82
+ allowlist does not permit. For a BI tool that means quietly returning
83
+ unfiltered numbers that look filtered, so Janela verifies every
84
+ supplied filter was applied and raises `Janela::Error` otherwise.
85
+
86
+ **Authorisation is a hook, not a dependency.** `query` accepts `on:`,
87
+ any relation, defaulting to `model.all`:
88
+
89
+ ```ruby
90
+ Order.janela.query(:revenue, on: policy_scope(Order))
91
+ ```
92
+
93
+ Pundit users recognise that line; CanCanCan users pass
94
+ `Order.accessible_by(current_ability)`. Janela does not depend on
95
+ either.
96
+
97
+ ## Consequences
98
+
99
+ - Janela's public filter API is coupled to Ransack's predicate naming.
100
+ Replacing the filter engine later is a breaking change. Accepted
101
+ deliberately: ADR 001 prefers one obvious way, and Ransack is the
102
+ obvious Rails way to filter.
103
+ - **A `through:` dimension requires the associated model to allowlist
104
+ the attribute itself.** Ransack's allowlist is per-class, so Janela
105
+ can only own the allowlist of the model the dimensions are declared
106
+ on. Associated models need both `ransackable_attributes` and
107
+ `ransackable_associations` defined or Ransack raises. This must be
108
+ documented prominently; it is the most likely source of confusion
109
+ for a first-time user.
110
+ - Filtering and grouping on the same association produces a redundant
111
+ aliased join, because Ransack builds its own join regardless of one
112
+ already being present. Results are correct; the cost will compound
113
+ as dashboards add through-dimensions. Revisit if it shows up in real
114
+ query plans, not before.
115
+ - Aggregation stays Janela's own code. No filter gem offers it, so
116
+ nothing in this decision reduces the surface area Janela maintains
117
+ for measures.
118
+ - Time-granularity dimensions (`granularity: :day`) are not built.
119
+ When they are, Groupdate is the conventional answer and should be
120
+ evaluated then. Time-zone-correct bucketing is genuinely fiddly,
121
+ and worth a dependency in a way filtering was not.
122
+ - Active Search, the search framework 37signals is introducing at
123
+ Rails World 2026, is text search across swappable engines and does
124
+ not overlap this decision. Worth revisiting only if it ships facet
125
+ counts, which are adjacent to slicer counts.
@@ -0,0 +1,104 @@
1
+ ---
2
+ Date: 2026-09-13
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 002
5
+ Triggers:
6
+ - changing how filter state is shared between visuals
7
+ - adding a visual type, or changing what a visual renders
8
+ - exposing a model or query over HTTP
9
+ - adding authentication or authorisation to dashboards
10
+ - deciding whether a behaviour needs a browser test
11
+ Topics: cross-filtering, stimulus, turbo, security, authorisation, testing
12
+ ---
13
+
14
+ # ADR 003: Cross-filtering with Turbo Frames
15
+
16
+ ## Context
17
+
18
+ Cross-filtering is the reason Janela exists. No gem in the Rails
19
+ ecosystem lets clicking a value in one visual re-scope every other
20
+ visual on the page, and ADR 001 named it one of the two things worth
21
+ building.
22
+
23
+ The obvious implementations all pull in machinery Janela does not
24
+ want: a JS framework holding client state, Turbo Streams broadcasting
25
+ updates, or a WebSocket. Each would contradict the first principle of
26
+ no JS framework and no build step.
27
+
28
+ Exposing visuals over HTTP also raises a question the DSL did not.
29
+ A visual is identified by a model, a measure and a dimension, and
30
+ those arrive as request parameters, so something has to stop a
31
+ parameter naming an arbitrary class.
32
+
33
+ ## Decision
34
+
35
+ **A visual is a Turbo Frame whose `src` carries the filters.** Turbo
36
+ reloads a frame whenever its `src` attribute changes, so a Stimulus
37
+ controller holding shared filter state only has to rewrite each
38
+ frame's `src`:
39
+
40
+ ```js
41
+ filtersValueChanged() {
42
+ this.visualTargets.forEach((visual) => {
43
+ const url = new URL(visual.dataset.janelaSrc, window.location.origin)
44
+ for (const [key, value] of Object.entries(this.filtersValue)) {
45
+ url.searchParams.set(`q[${key}]`, value)
46
+ }
47
+ if (visual.src !== url.href) visual.src = url.href
48
+ })
49
+ }
50
+ ```
51
+
52
+ No streams, no sockets, no state library. Filters are Ransack params
53
+ per ADR 002, so they stay readable and shareable in the URL. Clicking
54
+ a value already applied removes it.
55
+
56
+ **A visual ignores filters on its own dimension.** Otherwise clicking
57
+ "paid" in a revenue-by-status visual collapses that visual to the
58
+ single bar that was clicked. The rule is per-dimension rather than
59
+ per-visual: a visual grouped by region ignores region filters even
60
+ when a different visual originated them, so region totals stay
61
+ comparable. This is one line in `Janela::Visual` and needs no
62
+ tracking of which visual a filter came from.
63
+
64
+ **Only models that declare a `janela` block are addressable.**
65
+ Declaring the block registers the model's name, and the registry is
66
+ a strict allowlist, so a request parameter can never constantize an
67
+ arbitrary class. Names are stored rather than class objects so a
68
+ reloaded model leaves nothing stale behind. A lookup that misses in
69
+ development calls `eager_load!` rather than constantizing the
70
+ parameter to find out whether it is valid.
71
+
72
+ **Authorisation needs no configuration.** `Janela::ApplicationController`
73
+ inherits from `Janela.parent_controller` (the host's
74
+ `ApplicationController` by default), so the host's authentication
75
+ filters already apply. Scoping calls `policy_scope` when the host
76
+ defined it and falls back to `model.all`, which means Pundit users
77
+ get authorisation automatically without Janela depending on Pundit.
78
+
79
+ **Cross-filtering gets a browser test.** Request tests cannot prove
80
+ that clicking re-renders other frames, and that behaviour is the
81
+ product. `test/system/cross_filtering_test.rb` drives real Chrome;
82
+ CI runs it as a separate job so the unit matrix stays fast.
83
+
84
+ ## Consequences
85
+
86
+ - Each filter change refetches every visual, one request per frame.
87
+ Fine at the scale a dashboard page renders; a page with many
88
+ visuals will want debouncing or a combined endpoint later. Do not
89
+ optimise before a real dashboard shows the problem.
90
+ - Filter state lives in frame `src` attributes, not in the page URL,
91
+ so the browser back button does not step through filter changes and
92
+ a filtered dashboard cannot yet be shared as a link. Promoting
93
+ filter state to the page URL is the obvious next step and was left
94
+ out deliberately.
95
+ - The host registers the Stimulus controller explicitly
96
+ (`application.register("janela--dashboard", JanelaDashboardController)`).
97
+ One documented line, rather than Janela reaching into the host's
98
+ Stimulus instance.
99
+ - Visuals render as tables. Charts wrap an existing library off the
100
+ same server-supplied values and change nothing about the mechanism.
101
+ - `turbo-rails` and `stimulus-rails` become runtime dependencies,
102
+ which is what "built on Ruby and Stimulus" already implied.
103
+ - CI now needs Chrome for one job. Accepted because the alternative
104
+ is that the product's central claim is only ever verified by hand.
@@ -0,0 +1,135 @@
1
+ ---
2
+ Date: 2026-09-15
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 003
5
+ Triggers:
6
+ - changing how Janela's JavaScript reaches a host application
7
+ - adding or changing a chart type or the chart controller
8
+ - adding a JavaScript dependency
9
+ - a host reporting that no Janela controller connects
10
+ - securing or authenticating dashboard endpoints
11
+ Topics: javascript, npm, importmap, jsbundling, charts, chart.js, authorisation, packaging
12
+ ---
13
+
14
+ # ADR 004: Charts and JavaScript Delivery
15
+
16
+ ## Context
17
+
18
+ ADR 003 delivered Janela's Stimulus controller through the engine's
19
+ `config/importmap.rb`. Installing the gem into a real host application
20
+ for the v0.1.0 alpha showed that this reaches only importmap hosts. A
21
+ host that bundles JavaScript with esbuild, bun or webpack (jsbundling)
22
+ has no importmap, so the engine's pin is silently skipped and none of
23
+ Janela's JavaScript loads. The Turbo Frames still render, so the
24
+ failure is quiet: everything looks right and nothing cross-filters.
25
+
26
+ The alpha also required charts. Two questions had to be settled at
27
+ once: how a chart library reaches both kinds of host, and how a click
28
+ on a chart element becomes the same filter toggle a table button
29
+ already emits.
30
+
31
+ A third finding came from the same install. `Janela::ApplicationController`
32
+ inherits from the host's `ApplicationController`, which is only as
33
+ authenticated as the host makes it. In a host that authenticates per
34
+ controller, Janela's endpoints were public, and a host filter that
35
+ redirected to the sign-in page resolved its route against the engine's
36
+ route set rather than the host's.
37
+
38
+ ## Decision
39
+
40
+ **Janela ships as a gem and an npm package, from the same repository.**
41
+ The gem carries the engine; a root `package.json` exposes the same
42
+ Stimulus controllers as `@retail-tasker/janela` with `exports` for
43
+ `./dashboard_controller` and `./chart_controller`. Both halves install
44
+ from GitHub with no registry publish:
45
+
46
+ ```ruby
47
+ gem "janela", github: "retail-tasker/janela"
48
+ ```
49
+
50
+ ```bash
51
+ yarn add github:retail-tasker/janela
52
+ ```
53
+
54
+ The package name is scoped because `janela` is already taken on npm,
55
+ and because that is the convention Rails-adjacent packages follow
56
+ (`@hotwired/turbo-rails`, `@rails/actiontext`). The engine's importmap
57
+ pins stay for importmap hosts and for `test/dummy`.
58
+
59
+ **Charts are Chart.js, driven by one Stimulus controller.** The
60
+ `janela--chart` controller builds a chart on `connect` from data
61
+ attributes the view already renders (`labels`, `values`, the filter
62
+ key, the selected value) and destroys it on `disconnect`, which is
63
+ what makes a chart survive Turbo replacing its frame on every
64
+ cross-filter. `Chart.register(...registerables)` is called explicitly:
65
+ importing `{ Chart }` alone yields a Chart with no controllers and
66
+ `"bar" is not a registered controller` at runtime.
67
+
68
+ Chart.js reaches each host the way its other JavaScript does. Bundler
69
+ hosts declare it as a peer dependency and resolve it from their own
70
+ `node_modules`. Importmap hosts get a vendored, self-contained ESM
71
+ bundle at `app/assets/javascripts/janela/vendor/chart.js`, pinned as
72
+ `chart.js` unless the host already pins its own. The vendored file is
73
+ self-contained on purpose: Chart.js's own `dist/chart.js` imports a
74
+ `./chunks/` sibling that Propshaft digesting breaks, and the UMD
75
+ build sets a global instead of exporting.
76
+
77
+ **A chart click is a table click.** The chart controller's `onClick`
78
+ maps the hit element's index to its label and dispatches
79
+ `janela--chart:toggle` with `{ key, value }` in `detail`. The dashboard
80
+ controller's `toggle` reads `{ ...event.detail, ...event.params }`, so
81
+ a table button (params) and a chart (detail) arrive identically and the
82
+ dashboard cannot tell them apart.
83
+
84
+ **Renderer is a helper option and part of the frame identity.**
85
+ `janela_visual Order, :revenue, by: :status, as: :bar`. The renderer is
86
+ whitelisted (`table`, `bar`) and included in the frame id, so a table
87
+ and a chart of the same measure can share a page.
88
+
89
+ **Selection state is rendered by the server.** The filter on a
90
+ visual's own dimension is not applied to its query, but it is what the
91
+ user clicked, so the view marks it: `aria-pressed` on the table button,
92
+ a solid bar against faded siblings on the chart. Because the frame
93
+ reloads on every filter change, server-rendered state needs no
94
+ re-application in JavaScript.
95
+
96
+ **Securing endpoints is a documented host pattern, not a knob.**
97
+
98
+ ```ruby
99
+ Rails.application.config.to_prepare do
100
+ Janela::ApplicationController.prepend_before_action do
101
+ redirect_to main_app.new_session_path unless user_signed_in?
102
+ end
103
+ end
104
+ ```
105
+
106
+ Prepended, so it runs before filters on the host's `ApplicationController`
107
+ that assume a user (tenant lookup was the one that raised). Through
108
+ `main_app`, because inside an isolated engine a bare host route helper
109
+ resolves against the engine's routes. Blazer documents the same
110
+ `main_app` requirement.
111
+
112
+ ## Consequences
113
+
114
+ - The npm package's `files` glob excludes `vendor/`, so registry or
115
+ `github:` installs do not ship the vendored Chart.js. yarn 1's
116
+ `file:` protocol copies the whole directory regardless; harmless,
117
+ only relevant during local development against a sibling checkout.
118
+ - An importmap host with its own `chart.js` pin keeps it. The check is
119
+ `packages.key?("chart.js")` in the engine's `config/importmap.rb` and
120
+ depends on the host's importmap being evaluated first, which is the
121
+ order importmap-rails uses.
122
+ - Bar is the only chart type. Adding another is a new value in
123
+ `Visual::RENDERERS` and a `type` the controller passes through;
124
+ nothing about delivery or the click contract changes.
125
+ - Propshaft in development rescans assets only when the Rails
126
+ reloader fires, so a JavaScript-only change in an engine can appear
127
+ stale until a Ruby or view file changes or the server restarts.
128
+ Stale `public/assets` from an earlier `assets:precompile` shadows
129
+ `app/assets/builds` entirely. Both are host development gotchas
130
+ worth knowing when a controller "does not connect".
131
+ - A host whose sign-in route is not `new_session_path` adapts the one
132
+ line. If the pattern proves awkward across hosts, the Blazer-style
133
+ `Janela.before_action = :method_name` configuration is the fallback.
134
+ - CanCanCan hosts still get `model.all` silently (ADR 002). Unchanged
135
+ here and tracked as a pre-public issue.
@@ -0,0 +1,44 @@
1
+ # ADR Index
2
+
3
+ Topic-tagged map of all ADRs. Read this **before any non-trivial task**
4
+ to find which decisions are already made.
5
+
6
+ Every ADR carries `Triggers:` and `Topics:` near the top. Grep for
7
+ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
8
+
9
+ ## How to use
10
+
11
+ 1. Identify the area of the task (scope, DSL, cross-filtering, etc).
12
+ 2. Look up the topic below to see which ADRs are relevant.
13
+ 3. Read the relevant ADRs **before** writing code, advising, or making
14
+ a decision in that area.
15
+ 4. If the task spans topics, read all relevant ADRs.
16
+ 5. If no ADR covers the area but the decision is significant, draft a
17
+ new one.
18
+
19
+ ## Topics
20
+
21
+ | Topic | ADRs |
22
+ |-------|------|
23
+ | **Vision, scope, forkability** | 001 |
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 |
30
+ | **Security** | 003 |
31
+ | **Testing** | 003 |
32
+
33
+ ## Chronological
34
+
35
+ | ADR | Title | Date | Status |
36
+ |-----|-------|------|--------|
37
+ | 001 | Built to Be Forked | 2026-09-11 | Accepted |
38
+ | 002 | Measures and Dimensions over Ransack | 2026-09-13 | Accepted |
39
+ | 003 | Cross-filtering with Turbo Frames | 2026-09-13 | Accepted |
40
+ | 004 | Charts and JavaScript Delivery | 2026-09-15 | Accepted |
41
+
42
+ ## Next number
43
+
44
+ Next ADR: 005
@@ -0,0 +1,70 @@
1
+ module Janela
2
+ class Definition
3
+ attr_reader :model, :measures, :dimensions
4
+
5
+ def initialize(model)
6
+ @model = model
7
+ @measures = {}
8
+ @dimensions = {}
9
+ end
10
+
11
+ def measure(name, **aggregate)
12
+ measures[name] = Measure.build(name, **aggregate)
13
+ end
14
+
15
+ def dimension(name, through: nil)
16
+ dimensions[name] = Dimension.new(name, model: model, through: through)
17
+ end
18
+
19
+ # Filters are Ransack params, so a host can pass params[:q] straight
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)
23
+ relation = filter(on || model.all, where)
24
+
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
30
+
31
+ measure!(measure_name).apply(relation)
32
+ end
33
+
34
+ def dimension!(name)
35
+ dimensions.fetch(name) { raise Error, "#{model} has no janela dimension #{name.inspect}" }
36
+ end
37
+
38
+ def ransackable_attributes
39
+ dimensions.values.reject(&:through).map { |dimension| dimension.name.to_s }
40
+ end
41
+
42
+ def ransackable_associations
43
+ dimensions.values.filter_map(&:through).map(&:to_s).uniq
44
+ end
45
+
46
+ private
47
+ def filter(relation, params)
48
+ return relation if params.empty?
49
+
50
+ search = relation.ransack(params)
51
+ reject_dropped_filters!(search, params)
52
+ search.result
53
+ end
54
+
55
+ # Ransack silently discards conditions an allowlist does not permit,
56
+ # which would quietly return unfiltered numbers.
57
+ def reject_dropped_filters!(search, params)
58
+ applied = search.conditions.flat_map { |condition| condition.attributes.map(&:name) }
59
+ dropped = params.keys.reject { |key| applied.any? { |name| key.to_s.start_with?(name) } }
60
+ return if dropped.empty?
61
+
62
+ raise Error, "#{model} does not allow filtering on #{dropped.join(', ')}. " \
63
+ "Declare a janela dimension, or add it to ransackable_attributes."
64
+ end
65
+
66
+ def measure!(name)
67
+ measures.fetch(name) { raise Error, "#{model} has no janela measure #{name.inspect}" }
68
+ end
69
+ end
70
+ end