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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b37ede25ec758841e03748b59d6ebce1044ffeacc50fa38eea78a95a57f731be
4
- data.tar.gz: beae0fa4879019dfd161a5196bff0420b2053bccd6d7243a272bd3b173b77ce7
3
+ metadata.gz: 86e2bcbf47fdcf475b8e34b2b1897d308a6c9f478e7b3f0903979cd3fa6bb8e2
4
+ data.tar.gz: 9116c50f2f6e92a69489827acf9e9d580954c21bbe068887c3605b96d0531019
5
5
  SHA512:
6
- metadata.gz: fb70200d571c6ba005f868336d05e4fbe40d7acc720ecd73895c328b92429b2606fd806b9e8a42fe68ca8fb6f47423f57cb1af9fba3424d5c2d1561a299a56d6
7
- data.tar.gz: 0d78429d0dfdde06f0b77bdec85704d1f7d1a13e29046926937d9fb99346097d86f4420c74cea5035b48e22950813b11a53d8b4e5064e122eb7b128788803844
6
+ metadata.gz: a132498d5553317cd888139ba00559b5c7844c3878bdfe4c0c595783e14e3e4a082ec6fbe0a844d9f07a985a586daf4a4104164ff9dd452ed8ddf4390bdfe630
7
+ data.tar.gz: 5f35f6d28fe84c469e3502f97f8c0ec13b7e1c703f04c5aa21360ef7ad6e14b4cc9c8e8d174e5ac1af9207adcc221d3ed430e2851059ef41f08eb5833637712b
data/CHANGELOG.md CHANGED
@@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.1] - 2026-09-15
11
+
12
+ Fixes found by dogfooding 0.2.0 in a second application. 0.2.0 is unusable in any host whose layout contains a route helper, which is most of them.
13
+
14
+ ### Fixed
15
+
16
+ - Panes rendered in the host's application layout, so any route helper in it raised `NameError` inside the isolated engine and every pane 500'd. A pane in a Turbo Frame now carries no layout; opened directly it uses Janela's own minimal layout (ADR 011, #19).
17
+ - `average:` or `sum:` over a boolean column returned `true` instead of a ratio, because ActiveRecord casts an aggregate back through the column's type. Declaring one now raises and points at `dimension` instead (#20).
18
+ - A group whose dimension is null rendered as a blank label and filtered on an empty string. It is now labelled `(none)` and toggles Ransack's null predicate (#21).
19
+ - The README claimed time buckets follow `Time.zone`; on SQLite they are UTC, which silently shifts daily buckets by the host's offset (#22).
20
+
21
+ ### Changed
22
+
23
+ - A request for a model, measure, dimension or stored pane that does not exist is a 404; a renderer, granularity, limit or filter the request may not use is a 400. The response is a plain sentence, inside the requesting Turbo Frame when there is one, and the detail goes to the log instead of the client. `Janela::NotFound` and `Janela::BadRequest` subclass `Janela::Error`.
24
+
25
+ ## [0.2.0] - 2026-09-15
26
+
27
+ Breaking. Renames and a new URL scheme while the only installation is the author's own (ADR 005).
28
+
29
+ ### Changed
30
+
31
+ - `janela_visual` is now `janela_pane`; `Janela::Visual` is `Janela::Pane`; CSS hooks are `janela-pane`, `janela-chart`, `janela-value`, `janela-empty`.
32
+ - A pane's URL is `/<mount>/<model route key>/<measure>[/<dimension>]?as=bar&q[...]` instead of `/<mount>/visual?model=...`. Frame ids follow the same shape.
33
+ - The gem no longer ships its Rakefile, which referenced the unshipped dummy application.
34
+ - The helper finds the engine's mount in the host's routes, so `mount Janela::Engine => "/reports", as: :reports` works. Documentation and the dummy application mount at `/dashboards`.
35
+
36
+ ### Added
37
+
38
+ - A pane with no dimension renders the measure's single total.
39
+ - Time dimensions: `dimension :placed_on, granularity: :month` buckets with Groupdate (`hour` to `year`), gap-filled, labelled in Ruby. Override per pane with `?granularity=week` or `janela_pane ..., granularity: :week`. Time panes re-scope with the dashboard but are not yet click sources (ADR 006).
40
+ - `as: :line` renderer for time series.
41
+ - Groupdate is a runtime dependency.
42
+ - Published to rubygems.org through trusted publishing; a public demo of `test/dummy` deploys from `main`.
43
+ - Snapshots: `Janela::Snapshot.take` freezes several panes' results at one instant under one set of filters; `janela_snapshot_pane` renders a stored pane, static by nature, at `/snapshots/:id/<model>/<measure>[/<dimension>]`. `Janela::SnapshotJob` for scheduling. First migration: `rails janela:install:migrations` (ADR 009).
44
+ - Dashboard filters live in the page URL as `q[...]`; a reload keeps them and a filtered dashboard is shareable. The server renders the initial state from `params[:q]` (ADR 008).
45
+ - Category panes are ordered by their measure, largest first, in SQL. `?limit=N` / `janela_pane ..., limit: N` keeps the top N (ADR 007).
46
+ - `dimension :customer, through: :customer, column: :name` names a dimension for its meaning while reading another column.
47
+
48
+
10
49
  ## [0.1.0] - 2026-09-15
11
50
 
12
51
  First alpha, installed from GitHub for testing in a single host application.
@@ -23,4 +62,6 @@ First alpha, installed from GitHub for testing in a single host application.
23
62
  - Only models that declare a `janela` block are addressable over HTTP.
24
63
  - ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
25
64
 
65
+ [0.2.1]: https://github.com/retail-tasker/janela/releases/tag/v0.2.1
66
+ [0.2.0]: https://github.com/retail-tasker/janela/releases/tag/v0.2.0
26
67
  [0.1.0]: https://github.com/retail-tasker/janela/releases/tag/v0.1.0
data/README.md CHANGED
@@ -21,16 +21,16 @@ Janela's bet: the same dashboard definition should serve two audiences without b
21
21
 
22
22
  ## Installation
23
23
 
24
- Janela is pre-release and installed from GitHub. It has two halves, a gem and an npm package, installed the same way:
24
+ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It has two halves, a gem and an npm package:
25
25
 
26
26
  ```ruby
27
27
  # Gemfile
28
- gem "janela", github: "retail-tasker/janela"
28
+ gem "janela", "~> 0.2"
29
29
  ```
30
30
 
31
31
  ```ruby
32
32
  # config/routes.rb
33
- mount Janela::Engine => "/janela"
33
+ mount Janela::Engine => "/dashboards" # or /reports, or wherever you like
34
34
  ```
35
35
 
36
36
  Then register the two Stimulus controllers. How depends on how your app ships JavaScript.
@@ -79,16 +79,22 @@ class Order < ApplicationRecord
79
79
 
80
80
  dimension :status
81
81
  dimension :region, through: :customer
82
+ dimension :placed_on, granularity: :month
82
83
  end
83
84
  end
84
85
  ```
85
86
 
87
+ A dimension with a `granularity` is a time dimension. Groupdate buckets it (`hour`, `day`, `week`, `month`, `quarter`, `year`), fills empty buckets with zero, and uses your app's `Time.zone` and week start. **On SQLite, buckets are UTC**, because SQLite cannot convert time zones: with a non-UTC `Time.zone` a daily bucket is shifted by your offset, and an early-morning row lands in the previous day. Coarser granularities blunt the shift without removing it. If you need local-day buckets on SQLite, store a local date column and use it as a plain dimension.
88
+
86
89
  Then query them:
87
90
 
88
91
  ```ruby
89
92
  Order.janela.query(:revenue) # => 375
90
93
  Order.janela.query(:revenue, by: :status) # => { "paid" => 300, "refunded" => 50, "pending" => 25 }
91
94
  Order.janela.query(:revenue, by: :region) # => { "APAC" => 150, "EU" => 225 }
95
+ Order.janela.query(:revenue, by: :placed_on) # => { "Sep 2026" => 375 }
96
+ Order.janela.query(:revenue, by: :placed_on, granularity: :day)
97
+ # => { "2026-09-01" => 100, "2026-09-02" => 50, ... }
92
98
  ```
93
99
 
94
100
  Filters are [Ransack](https://github.com/activerecord-hackery/ransack) params, so a slicer built with `search_form_for` can pass `params[:q]` straight through:
@@ -114,21 +120,68 @@ end
114
120
 
115
121
  ### Dashboards
116
122
 
117
- Compose visuals on any page. Each visual is a Turbo Frame; clicking a value in one re-scopes the others:
123
+ Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one re-scopes the others:
118
124
 
119
125
  ```erb
120
126
  <%= janela_dashboard do %>
121
127
  <button type="button" data-action="janela--dashboard#clear">Clear filters</button>
122
128
 
123
- <%= janela_visual Order, :revenue, by: :status, as: :bar %>
124
- <%= janela_visual Order, :revenue, by: :region %>
125
- <%= janela_visual Order, :orders, by: :region %>
129
+ <%= janela_pane Order, :revenue %>
130
+ <%= janela_pane Order, :revenue, by: :status, as: :bar %>
131
+ <%= janela_pane Order, :revenue, by: :region %>
132
+ <%= janela_pane Order, :orders, by: :region %>
126
133
  <% end %>
127
134
  ```
128
135
 
129
- `as:` is `:table` by default or `:bar` for a Chart.js bar chart. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
136
+ A pane with no `by:` is the measure's single total, the KPI tile. `limit: 10` keeps the top ten rows or bars. `as:` is `:table` by default, `:bar` for a Chart.js bar chart, or `:line`, which suits a time dimension: `janela_pane Order, :revenue, by: :placed_on, as: :line, granularity: :week`. A chart fills its container's width at Chart.js's default aspect ratio, so wrap it in an element with the width you want. Clicking a bar does exactly what clicking a table value does.
137
+
138
+ The dashboard's filters live in the page URL as the same `q[...]` parameters, so a reload keeps them and a filtered dashboard is a link you can send: `/reports/orders?q[status_eq]=paid` renders filtered before any JavaScript runs. A pane ignores filters on its own dimension, so clicking a value re-scopes the rest of the dashboard rather than collapsing the pane you clicked. Time panes re-scope with the others but are not click sources yet; drill-down is the next decision. The selected value is marked `aria-pressed="true"` on tables and drawn solid against faded siblings on charts, so it can be styled and read. A pane with no matching rows renders a `.janela-empty` paragraph. A group whose dimension is null is labelled `(none)` and filters with Ransack's null predicate rather than an empty string. Only models that declare a `janela` block can be requested over HTTP.
139
+
140
+ ### Pane URLs
141
+
142
+ Every pane has its own URL under the mount, and a Turbo Frame in a dashboard loads exactly the same URL a person can open directly:
143
+
144
+ ```
145
+ /dashboards/orders/revenue orders revenue
146
+ /dashboards/orders/revenue/status orders revenue by status
147
+ /dashboards/orders/revenue/status?as=bar ... as a bar chart
148
+ /dashboards/orders/revenue/region?q[status_eq]=paid
149
+ orders revenue by region where status is paid
150
+ /dashboards/orders/revenue/placed_on?granularity=week&as=line
151
+ orders revenue by placed_on, per week, as a line
152
+ ```
153
+
154
+ The model is its route key (`orders`, `sales_orders`), then the measure, then optionally the dimension. Where an analyst would say *by*, the URL has a `/`; *where* is a `q` filter; *as a bar chart* is `?as=bar`; *top ten* is `?limit=10`; *as of* a snapshot is `/snapshots/:id/` in front. Category panes are always ordered by the measure, largest first; time panes are chronological. A pane opened on its own renders inside your application layout with its filters applied, so a filtered pane is a link you can send someone. ADR 005 has the reasoning.
155
+
156
+ ### Snapshots
157
+
158
+ A snapshot freezes the results of several panes at one instant, under one set of filters, so an audience sees exactly what was signed off while the live dashboard stays editable. Results are stored, not HTML; a stored pane can still be drawn as a table or a chart.
159
+
160
+ ```bash
161
+ bin/rails janela:install:migrations && bin/rails db:migrate
162
+ ```
163
+
164
+ ```ruby
165
+ Janela::Snapshot.take(name: "September 2026", filters: { status_eq: "paid" }) do |take|
166
+ take.pane Order, :revenue, on: policy_scope(Order)
167
+ take.pane Order, :revenue, by: :status, on: policy_scope(Order)
168
+ take.pane Order, :revenue, by: :placed_on, granularity: :week
169
+ end
170
+ ```
171
+
172
+ Render a stored pane the same way you render a live one:
173
+
174
+ ```erb
175
+ <%= janela_snapshot_pane @snapshot, Order, :revenue, by: :status, as: :bar %>
176
+ ```
177
+
178
+ Panes render in Janela's own minimal layout when opened directly, so a shared pane link shows its numbers but no host styling and no charts; set `Janela::ApplicationController.layout "application"` in an initializer to use your own layout, which must not call a bare host route helper (inside an engine those need a `main_app.` prefix). Inside a dashboard, panes are Turbo Frames and carry no layout at all.
179
+
180
+ Stored panes are static by nature: no filter buttons, charts ignore clicks, and the URL says *as of*: `/dashboards/snapshots/42/orders/revenue/status`. Request filters are ignored because the snapshot's were fixed when it was taken.
181
+
182
+ `Janela::SnapshotJob.perform_later(name:, panes: [{ "model" => "orders", "measure" => "revenue", "by" => "status" }])` takes one from serialisable arguments so you can schedule it with whatever runs your jobs. The job uses each model's default scope; if you scope by tenant, write your own job around `Snapshot.take` and pass `on:`.
130
183
 
131
- A visual ignores filters on its own dimension, so clicking a value re-scopes the rest of the dashboard rather than collapsing the visual you clicked. The selected value is marked `aria-pressed="true"` on tables and drawn solid against faded siblings on charts, so it can be styled and read. A visual with no matching rows renders a `.janela-empty` paragraph. Only models that declare a `janela` block can be requested over HTTP.
184
+ Who may see a snapshot is your decision. Stored panes go through the same controllers as live ones, so your authentication applies; an external audience gets a page you build over `janela_snapshot_pane` behind whatever share tokens you already trust. ADR 009 has the reasoning.
132
185
 
133
186
  ### Securing dashboards
134
187
 
@@ -153,7 +206,7 @@ Janela ships the load-bearing core of a BI tool and nothing else. The reasoning
153
206
 
154
207
  - **Measures and dimensions are a Ruby DSL on the model**, config-as-code like `routes.rb`. No drag-and-drop designer.
155
208
  - **Querying rides on [Ransack](https://github.com/activerecord-hackery/ransack)'s association-path traversal.** Janela does not invent a query language.
156
- - **Cross-filtering is a Stimulus controller plus Turbo Frames.** Click a value in one visual, shared filter state updates, every other frame on the page re-renders.
209
+ - **Cross-filtering is a Stimulus controller plus Turbo Frames.** Click a value in one pane, shared filter state updates, every other frame on the page re-renders.
157
210
  - **Charts are [Chart.js](https://www.chartjs.org)**, driven by one small Stimulus controller from the same values the tables show. Not a charting engine.
158
211
  - **Publishing creates a Snapshot.** An ActiveJob freezes the result set into a new record; the live dashboard stays editable and the published view is a point-in-time fork, not a toggle on the same record. Not built yet.
159
212
 
@@ -161,7 +214,7 @@ Deliberately out of scope: report designer UI, natural-language query, a separat
161
214
 
162
215
  ## Status
163
216
 
164
- **v0.1.0 alpha.** The measures/dimensions DSL, cross-filtering, and bar charts work and are covered by unit and real-browser tests. Not yet built: time-granularity dimensions, other chart types, filter state in the page URL, published snapshots. Open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
217
+ **v0.2.1 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering, bar and line charts, pane URLs, shareable dashboard URLs and snapshots work and are covered by unit and real-browser tests. Not yet built: drill-down on time panes, other chart types. Open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
165
218
 
166
219
  ## Development
167
220
 
@@ -3,27 +3,34 @@ import { Chart, registerables } from "chart.js"
3
3
 
4
4
  Chart.register(...registerables)
5
5
 
6
- // Renders one visual as a chart and turns a click on a bar into the same
6
+ // Renders one pane as a chart and turns a click on a bar into the same
7
7
  // toggle event a table button emits, so the dashboard controller cannot tell
8
8
  // the difference. Turbo replaces the frame on every cross-filter, so the chart
9
9
  // is destroyed on disconnect and rebuilt on connect.
10
10
  export default class extends Controller {
11
- static values = { type: String, labels: Array, values: Array, key: String, title: String, selected: String }
11
+ static values = { type: String, labels: Array, values: Array, filters: Object, title: String, selected: String }
12
12
 
13
13
  connect() {
14
14
  this.chart = new Chart(this.element, {
15
15
  type: this.typeValue,
16
16
  data: {
17
17
  labels: this.labelsValue,
18
- datasets: [{ label: this.titleValue, data: this.valuesValue, backgroundColor: this.colours() }]
18
+ datasets: [{
19
+ label: this.titleValue,
20
+ data: this.valuesValue,
21
+ backgroundColor: this.colours(),
22
+ borderColor: "rgba(54, 162, 235, 0.9)"
23
+ }]
19
24
  },
20
25
  options: {
21
26
  animation: false,
27
+ scales: { y: { beginAtZero: true } },
22
28
  plugins: { legend: { display: false } },
23
29
  onClick: (_event, elements) => {
24
30
  if (elements.length === 0) return
25
- const value = this.labelsValue[elements[0].index]
26
- this.dispatch("toggle", { detail: { key: this.keyValue, value } })
31
+ const label = this.labelsValue[elements[0].index]
32
+ const [key, value] = this.filtersValue[String(label)] || []
33
+ if (key) this.dispatch("toggle", { detail: { key, value } })
27
34
  }
28
35
  }
29
36
  })
@@ -1,10 +1,10 @@
1
1
  import { Controller } from "@hotwired/stimulus"
2
2
 
3
- // Shared filter state for every visual on the page. Clicking a value rewrites
3
+ // Shared filter state for every pane on the page. Clicking a value rewrites
4
4
  // each frame's src, and Turbo reloads a frame whenever its src changes, so
5
5
  // cross-filtering needs no streams, no sockets and no state library.
6
6
  export default class extends Controller {
7
- static targets = ["visual"]
7
+ static targets = ["pane"]
8
8
  static values = { filters: Object }
9
9
 
10
10
  // Table buttons send key/value as Stimulus action params; charts dispatch a
@@ -27,14 +27,32 @@ export default class extends Controller {
27
27
  }
28
28
 
29
29
  filtersValueChanged() {
30
- this.visualTargets.forEach((visual) => {
31
- const url = new URL(visual.dataset.janelaSrc, window.location.origin)
30
+ this.paneTargets.forEach((pane) => {
31
+ const url = new URL(pane.dataset.janelaSrc, window.location.origin)
32
+ this.writeFilters(url)
33
+ if (pane.src !== url.href) pane.src = url.href
34
+ })
32
35
 
33
- for (const [key, value] of Object.entries(this.filtersValue)) {
34
- url.searchParams.set(`q[${key}]`, value)
35
- }
36
+ this.syncPageUrl()
37
+ }
36
38
 
37
- if (visual.src !== url.href) visual.src = url.href
38
- })
39
+ // The page URL carries the same q[...] the panes do, so a reload or a
40
+ // pasted link opens the dashboard filtered (ADR 008). Replaced rather than
41
+ // pushed: a click is not a place the back button should return to.
42
+ syncPageUrl() {
43
+ const url = new URL(window.location.href)
44
+ for (const key of [...url.searchParams.keys()]) {
45
+ if (key.startsWith("q[")) url.searchParams.delete(key)
46
+ }
47
+ this.writeFilters(url)
48
+ if (url.href !== window.location.href) history.replaceState(history.state, "", url)
49
+ }
50
+
51
+ // Sorted so the browser serialises filters the same way the server does and
52
+ // an unchanged src is never reloaded.
53
+ writeFilters(url) {
54
+ for (const key of Object.keys(this.filtersValue).sort()) {
55
+ url.searchParams.set(`q[${key}]`, this.filtersValue[key])
56
+ }
39
57
  }
40
58
  }
@@ -1,5 +1,13 @@
1
1
  module Janela
2
2
  class ApplicationController < Janela.parent_controller.constantize
3
+ # A frame request needs no layout, since Turbo keeps only the matching
4
+ # frame. A direct request gets Janela's own minimal layout, because a host
5
+ # layout's route helpers cannot resolve inside an isolated engine (ADR 011).
6
+ layout -> { turbo_frame_request? ? false : "janela/application" }
7
+
8
+ rescue_from Janela::NotFound, with: :janela_not_found
9
+ rescue_from Janela::BadRequest, with: :janela_bad_request
10
+
3
11
  private
4
12
  # Pundit defines policy_scope on the host's ApplicationController, which
5
13
  # this inherits from, so authorisation applies without Janela depending
@@ -7,5 +15,24 @@ module Janela
7
15
  def janela_scope(model)
8
16
  respond_to?(:policy_scope, true) ? policy_scope(model) : model.all
9
17
  end
18
+
19
+ def janela_not_found(error)
20
+ janela_error(error, :not_found, "There is no such pane.")
21
+ end
22
+
23
+ def janela_bad_request(error)
24
+ janela_error(error, :bad_request, "That request is not allowed on this pane.")
25
+ end
26
+
27
+ # The detail names models and filter keys, so it goes to the log; the
28
+ # client sees a plain sentence. A Turbo Frame request gets its frame
29
+ # back so the dashboard shows the sentence where the pane would be.
30
+ def janela_error(error, status, message)
31
+ logger.warn("Janela: #{error.message}")
32
+ body = view_context.tag.p(message, class: "janela-pane janela-error")
33
+ frame = request.headers["Turbo-Frame"]
34
+ body = view_context.turbo_frame_tag(frame) { body } if frame.present?
35
+ render html: body, status: status, layout: frame.blank?
36
+ end
10
37
  end
11
38
  end
@@ -1,15 +1,17 @@
1
1
  module Janela
2
- class VisualsController < ApplicationController
2
+ class PanesController < ApplicationController
3
3
  def show
4
- @visual = Visual.new(
4
+ @pane = Pane.new(
5
5
  definition: Janela.definition!(params.require(:model)),
6
6
  measure: params.require(:measure).to_sym,
7
- dimension: params.require(:by).to_sym,
7
+ dimension: params[:dimension]&.to_sym,
8
8
  renderer: params.fetch(:as, "table"),
9
+ granularity: params[:granularity],
10
+ limit: params[:limit],
9
11
  filters: filters
10
12
  )
11
13
 
12
- @result = @visual.result(on: janela_scope(@visual.model))
14
+ @result = @pane.result(on: janela_scope(@pane.model))
13
15
  end
14
16
 
15
17
  private
@@ -0,0 +1,21 @@
1
+ module Janela
2
+ # A pane as it was when a snapshot was taken. Filters in the request are
3
+ # ignored: they were fixed at taking (ADR 009).
4
+ class SnapshotPanesController < ApplicationController
5
+ def show
6
+ snapshot = Snapshot.find(params.require(:snapshot_id))
7
+ @pane = Pane.new(
8
+ definition: Janela.definition!(params.require(:model)),
9
+ measure: params.require(:measure).to_sym,
10
+ dimension: params[:dimension]&.to_sym,
11
+ renderer: params.fetch(:as, "table"),
12
+ granularity: params[:granularity],
13
+ limit: params[:limit],
14
+ snapshot: snapshot
15
+ )
16
+
17
+ @result = @pane.result
18
+ render "janela/panes/show"
19
+ end
20
+ end
21
+ end
@@ -1,16 +1,49 @@
1
1
  module Janela
2
2
  module DashboardHelper
3
+ # The page URL carries the dashboard's filters as q[...] (ADR 008), so a
4
+ # shared link renders filtered before any JavaScript runs.
3
5
  def janela_dashboard(&block)
4
- tag.div(data: { controller: "janela--dashboard" }, &block)
6
+ tag.div(data: { controller: "janela--dashboard", janela__dashboard_filters_value: janela_page_filters.to_json }, &block)
5
7
  end
6
8
 
7
- def janela_visual(model, measure, by:, as: :table)
8
- src = janela.visual_path(model: model.name, measure: measure, by: by, as: as)
9
+ def janela_pane(model, measure, by: nil, as: :table, granularity: nil, limit: nil)
10
+ query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit }.compact
11
+ base = janela_routes.pane_path(model.model_name.route_key, measure, by, **query)
12
+ src = janela_page_filters.empty? ? base : janela_routes.pane_path(model.model_name.route_key, measure, by, **query, q: janela_page_filters)
9
13
 
10
- turbo_frame_tag Visual.frame_id(model: model.name, measure: measure, by: by, as: as),
14
+ turbo_frame_tag Pane.frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit),
11
15
  src: src,
12
16
  loading: :lazy,
13
- data: { janela__dashboard_target: "visual", janela_src: src }
17
+ data: { janela__dashboard_target: "pane", janela_src: base }
14
18
  end
19
+
20
+ # A pane as it was when the snapshot was taken: same shape as janela_pane,
21
+ # not part of the live dashboard's filter state (ADR 009).
22
+ def janela_snapshot_pane(snapshot, model, measure, by: nil, as: :table, granularity: nil, limit: nil)
23
+ query = { as: (as unless as.to_s == "table"), granularity: granularity, limit: limit }.compact
24
+ src = janela_routes.snapshot_pane_path(snapshot, model.model_name.route_key, measure, by, **query)
25
+
26
+ turbo_frame_tag Pane.frame_id(model: model, measure: measure, by: by, as: as, granularity: granularity, limit: limit, snapshot: snapshot),
27
+ src: src, loading: :lazy
28
+ end
29
+
30
+ private
31
+ def janela_page_filters
32
+ @janela_page_filters ||= begin
33
+ q = request.query_parameters["q"]
34
+ q.is_a?(Hash) ? q.sort.to_h : {}
35
+ end
36
+ end
37
+
38
+ # The host chooses where and under what name the engine is mounted, so
39
+ # the route proxy is looked up rather than assumed to be `janela`.
40
+ def janela_routes
41
+ @janela_routes ||= begin
42
+ mount = Rails.application.routes.routes.find { |route| route.app.respond_to?(:app) && route.app.app == Janela::Engine }
43
+ raise Error, "Janela::Engine is not mounted in the host application's routes" unless mount
44
+
45
+ public_send(mount.name)
46
+ end
47
+ end
15
48
  end
16
49
  end
@@ -0,0 +1,18 @@
1
+ module Janela
2
+ # Takes a snapshot from serialisable arguments so a host can schedule it
3
+ # with whatever runs its jobs. Each pane uses its model's default scope; a
4
+ # host that scopes by tenant writes its own job around Snapshot.take and
5
+ # passes on: (ADR 009).
6
+ class SnapshotJob < ActiveJob::Base
7
+ def perform(name:, panes:, filters: {})
8
+ Snapshot.take(name: name, filters: filters) do |take|
9
+ panes.each do |pane|
10
+ pane = pane.to_h.stringify_keys
11
+ model = Janela.definition!(pane.fetch("model")).model
12
+ take.pane(model, pane.fetch("measure").to_sym,
13
+ by: pane["by"]&.to_sym, granularity: pane["granularity"], limit: pane["limit"])
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,137 @@
1
+ module Janela
2
+ # One pane of a dashboard: a measure, optionally grouped by a dimension,
3
+ # rendered as a single value, a table or a chart. A pane ignores filters on
4
+ # its own dimension so that clicking a value re-scopes the other panes
5
+ # rather than collapsing this one to the value clicked. A pane read from a
6
+ # snapshot shows stored results and cannot be clicked at all.
7
+ class Pane
8
+ RENDERERS = %w[table bar line].freeze
9
+
10
+ attr_reader :definition, :measure, :dimension, :renderer, :limit, :filters, :snapshot
11
+
12
+ # The helper renders the frame and the controller renders its replacement,
13
+ # so both derive the id the same way from the same parameters.
14
+ def self.frame_id(model:, measure:, by: nil, as: :table, granularity: nil, limit: nil, snapshot: nil)
15
+ parts = [ "janela", ("snapshot_#{snapshot.id}" if snapshot), model.model_name.route_key, measure, by,
16
+ (granularity if by), (as unless by.nil?), ("top#{limit}" if by && limit) ]
17
+ parts.compact.join("_")
18
+ end
19
+
20
+ def initialize(definition:, measure:, dimension: nil, renderer: "table", granularity: nil, limit: nil, filters: {}, snapshot: nil)
21
+ @definition = definition
22
+ @measure = measure
23
+ @dimension = dimension
24
+ @renderer = renderer.to_s
25
+ @filters = filters
26
+ @snapshot = snapshot
27
+
28
+ raise BadRequest, "unknown pane renderer #{renderer.inspect}" unless RENDERERS.include?(@renderer)
29
+ @granularity = Dimension.granularity!(granularity) if granularity.present?
30
+ @limit = definition.limit!(limit) if limit.present?
31
+ end
32
+
33
+ def model
34
+ definition.model
35
+ end
36
+
37
+ def single_value?
38
+ dimension.nil?
39
+ end
40
+
41
+ def time?
42
+ !single_value? && dimension_definition.time?
43
+ end
44
+
45
+ def frozen?
46
+ !snapshot.nil?
47
+ end
48
+
49
+ def granularity
50
+ @granularity || (dimension_definition.granularity if time?)
51
+ end
52
+
53
+ # Clicking a category adds one Ransack condition; clicking a time bucket
54
+ # would need two, and the dashboard toggles one key at a time (ADR 006).
55
+ # A stored pane is the record of a moment and is not clickable (ADR 009).
56
+ def clickable?
57
+ !single_value? && !time? && !frozen?
58
+ end
59
+
60
+ def chart?
61
+ !single_value? && renderer != "table"
62
+ end
63
+
64
+ def frame_id
65
+ self.class.frame_id(model: model, measure: measure, by: dimension, as: renderer,
66
+ granularity: @granularity, limit: limit, snapshot: snapshot)
67
+ end
68
+
69
+ def title
70
+ base = if single_value?
71
+ measure.to_s.humanize
72
+ else
73
+ by = "#{measure.to_s.humanize} by #{dimension.to_s.humanize}"
74
+ time? ? "#{by} per #{granularity}" : by
75
+ end
76
+ frozen? ? "#{base} as of #{snapshot.taken_at.strftime('%-d %b %Y')}" : base
77
+ end
78
+
79
+ # What identifies this pane's data inside a snapshot.
80
+ def lookup_key
81
+ { "model" => model.model_name.route_key, "measure" => measure.to_s, "dimension" => dimension&.to_s,
82
+ "granularity" => granularity&.to_s, "limit" => limit }
83
+ end
84
+
85
+ def result(on: nil)
86
+ return snapshot.stored_result(self) if frozen?
87
+
88
+ definition.query(measure, by: dimension, where: applicable_filters, on: on, granularity: granularity, limit: limit)
89
+ end
90
+
91
+ # The Ransack key and value a click on this label should toggle. A null
92
+ # group filters with the null predicate, not an empty string (ADR 009 has
93
+ # no say here; see issue #21).
94
+ def filter_params(label)
95
+ return [ nil, nil ] unless clickable?
96
+ return [ "#{ransack_name}_null", "1" ] if label.to_s == Dimension::NONE
97
+
98
+ [ "#{ransack_name}_eq", label.to_s ]
99
+ end
100
+
101
+ # Every label in this pane paired with the filter it toggles, for a chart
102
+ # to look up by label when a bar is clicked.
103
+ def filters_for(labels)
104
+ return {} unless clickable?
105
+
106
+ labels.to_h { |label| [ label.to_s, filter_params(label) ] }
107
+ end
108
+
109
+ # The filter on this pane's own dimension is not applied to its query, but
110
+ # it is what the user clicked here, so the view highlights it.
111
+ def selected_value
112
+ return unless clickable?
113
+ return Dimension::NONE if filter("#{ransack_name}_null").present?
114
+
115
+ filter("#{ransack_name}_eq")
116
+ end
117
+
118
+ private
119
+ def filter(key)
120
+ filters[key] || filters[key.to_sym]
121
+ end
122
+
123
+ def dimension_definition
124
+ definition.dimension!(dimension)
125
+ end
126
+
127
+ def ransack_name
128
+ dimension_definition.ransack_name
129
+ end
130
+
131
+ def applicable_filters
132
+ return filters if single_value? || time?
133
+
134
+ filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,51 @@
1
+ module Janela
2
+ # A snapshot freezes the results of several panes at one instant under one
3
+ # set of filters, so an audience sees exactly what was signed off while the
4
+ # live dashboard stays editable (ADR 009). Results are stored, not HTML; the
5
+ # renderer is chosen by whoever shows a stored pane.
6
+ class Snapshot < ActiveRecord::Base
7
+ attribute :filters, default: -> { {} }
8
+ attribute :panes, default: -> { [] }
9
+
10
+ validates :name, :taken_at, presence: true
11
+
12
+ def self.take(name:, filters: {}, taken_at: Time.current)
13
+ taking = Taking.new(filters.to_h.stringify_keys)
14
+ yield taking
15
+ create!(name: name, taken_at: taken_at, filters: taking.filters, panes: taking.panes)
16
+ end
17
+
18
+ def stored_result(pane)
19
+ key = pane.lookup_key
20
+ entry = panes.find { |stored| stored.slice(*key.keys) == key }
21
+ raise NotFound, "snapshot #{id} has no pane #{key.compact.values.join(' ')}" unless entry
22
+
23
+ entry["result"]
24
+ end
25
+
26
+ class Taking
27
+ attr_reader :filters, :panes
28
+
29
+ def initialize(filters)
30
+ @filters = filters
31
+ @panes = []
32
+ end
33
+
34
+ def pane(model, measure, by: nil, granularity: nil, limit: nil, on: nil)
35
+ pane = Pane.new(definition: model.janela, measure: measure, dimension: by,
36
+ granularity: granularity, limit: limit, filters: filters)
37
+ panes << pane.lookup_key.merge("result" => plain(pane.result(on: on)))
38
+ end
39
+
40
+ private
41
+ # BigDecimal would otherwise be encoded as a JSON string.
42
+ def plain(result)
43
+ case result
44
+ when Hash then result.transform_values { |v| plain(v) }
45
+ when BigDecimal then result.to_f
46
+ else result
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end