janela 0.1.0 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b37ede25ec758841e03748b59d6ebce1044ffeacc50fa38eea78a95a57f731be
4
- data.tar.gz: beae0fa4879019dfd161a5196bff0420b2053bccd6d7243a272bd3b173b77ce7
3
+ metadata.gz: 8f1be37d3c8dc019195214b51f66d263d13032c3f12e44861620fd2d4f1cbe88
4
+ data.tar.gz: be62c3edcf0f95b897c22a7b348b76168398ee6f3c801230236a3195bda9aae6
5
5
  SHA512:
6
- metadata.gz: fb70200d571c6ba005f868336d05e4fbe40d7acc720ecd73895c328b92429b2606fd806b9e8a42fe68ca8fb6f47423f57cb1af9fba3424d5c2d1561a299a56d6
7
- data.tar.gz: 0d78429d0dfdde06f0b77bdec85704d1f7d1a13e29046926937d9fb99346097d86f4420c74cea5035b48e22950813b11a53d8b4e5064e122eb7b128788803844
6
+ metadata.gz: 63ef09cab8969ea61002ac8273304222ef40d3d11cc5d5470eecab7a26ef57d84186afb44b58b6bcbeb25a336ae16e91fbc45289199a2163ed3a203bef739ff8
7
+ data.tar.gz: 05462c455475cadb4dc633e76ea3181eeba0d169c9f82090a715a39afb375b92adebba3ca4de4db9e41c03d6a3dd53a641017ee5605e3c90280c18ab3c5dba1a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-09-15
11
+
12
+ Breaking. Renames and a new URL scheme while the only installation is the author's own (ADR 005).
13
+
14
+ ### Changed
15
+
16
+ - `janela_visual` is now `janela_pane`; `Janela::Visual` is `Janela::Pane`; CSS hooks are `janela-pane`, `janela-chart`, `janela-value`, `janela-empty`.
17
+ - 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.
18
+ - The gem no longer ships its Rakefile, which referenced the unshipped dummy application.
19
+ - 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`.
20
+
21
+ ### Added
22
+
23
+ - A pane with no dimension renders the measure's single total.
24
+ - 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).
25
+ - `as: :line` renderer for time series.
26
+ - Groupdate is a runtime dependency.
27
+ - Published to rubygems.org through trusted publishing; a public demo of `test/dummy` deploys from `main`.
28
+ - 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).
29
+ - 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).
30
+ - Category panes are ordered by their measure, largest first, in SQL. `?limit=N` / `janela_pane ..., limit: N` keeps the top N (ADR 007).
31
+ - `dimension :customer, through: :customer, column: :name` names a dimension for its meaning while reading another column.
32
+
33
+
10
34
  ## [0.1.0] - 2026-09-15
11
35
 
12
36
  First alpha, installed from GitHub for testing in a single host application.
@@ -23,4 +47,5 @@ First alpha, installed from GitHub for testing in a single host application.
23
47
  - Only models that declare a `janela` block are addressable over HTTP.
24
48
  - ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
25
49
 
50
+ [0.2.0]: https://github.com/retail-tasker/janela/releases/tag/v0.2.0
26
51
  [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.
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,66 @@ 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. 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
+ Stored panes are static by nature: no filter buttons, charts ignore clicks, and the URL says *as of*: `/dashboards/snapshots/42/orders/revenue/status`. Request filters are ignored because the snapshot's were fixed when it was taken.
179
+
180
+ `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
181
 
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.
182
+ 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
183
 
133
184
  ### Securing dashboards
134
185
 
@@ -153,7 +204,7 @@ Janela ships the load-bearing core of a BI tool and nothing else. The reasoning
153
204
 
154
205
  - **Measures and dimensions are a Ruby DSL on the model**, config-as-code like `routes.rb`. No drag-and-drop designer.
155
206
  - **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.
207
+ - **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
208
  - **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
209
  - **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
210
 
@@ -161,7 +212,7 @@ Deliberately out of scope: report designer UI, natural-language query, a separat
161
212
 
162
213
  ## Status
163
214
 
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).
215
+ **v0.2.0 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
216
 
166
217
  ## Development
167
218
 
@@ -3,7 +3,7 @@ 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.
@@ -15,13 +15,19 @@ export default class extends Controller {
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
- if (elements.length === 0) return
30
+ if (!this.keyValue || elements.length === 0) return
25
31
  const value = this.labelsValue[elements[0].index]
26
32
  this.dispatch("toggle", { detail: { key: this.keyValue, value } })
27
33
  }
@@ -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,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,118 @@
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 Error, "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
+ def filter_key
92
+ "#{ransack_name}_eq" if clickable?
93
+ end
94
+
95
+ # The filter on this pane's own dimension is not applied to its query, but
96
+ # it is what the user clicked here, so the view highlights it.
97
+ def selected_value
98
+ return unless clickable?
99
+
100
+ filters[filter_key] || filters[filter_key.to_sym]
101
+ end
102
+
103
+ private
104
+ def dimension_definition
105
+ definition.dimension!(dimension)
106
+ end
107
+
108
+ def ransack_name
109
+ dimension_definition.ransack_name
110
+ end
111
+
112
+ def applicable_filters
113
+ return filters if single_value? || time?
114
+
115
+ filters.reject { |key, _| key.to_s.start_with?(ransack_name) }
116
+ end
117
+ end
118
+ 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 Error, "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
@@ -0,0 +1,45 @@
1
+ <%= turbo_frame_tag @pane.frame_id do %>
2
+ <% if @pane.single_value? %>
3
+ <p class="janela-pane janela-value">
4
+ <span class="janela-value-label"><%= @pane.title %></span>
5
+ <strong class="janela-value-number"><%= number_with_delimiter(@result || 0) %></strong>
6
+ </p>
7
+ <% elsif @result.empty? %>
8
+ <p class="janela-pane janela-empty"><%= @pane.title %>: no data</p>
9
+ <% elsif @pane.chart? %>
10
+ <canvas class="janela-pane janela-chart"
11
+ data-controller="janela--chart"
12
+ data-action="janela--chart:toggle->janela--dashboard#toggle"
13
+ data-janela--chart-type-value="<%= @pane.renderer %>"
14
+ data-janela--chart-title-value="<%= @pane.title %>"
15
+ data-janela--chart-key-value="<%= @pane.filter_key %>"
16
+ data-janela--chart-selected-value="<%= @pane.selected_value %>"
17
+ data-janela--chart-labels-value="<%= @result.keys.to_json %>"
18
+ data-janela--chart-values-value="<%= @result.values.map(&:to_f).to_json %>"
19
+ role="img" aria-label="<%= @pane.title %>"></canvas>
20
+ <% else %>
21
+ <table class="janela-pane">
22
+ <caption><%= @pane.title %></caption>
23
+ <tbody>
24
+ <% @result.each do |value, measured| %>
25
+ <tr>
26
+ <td>
27
+ <% if @pane.clickable? %>
28
+ <button type="button"
29
+ aria-pressed="<%= value.to_s == @pane.selected_value.to_s && @pane.selected_value.present? %>"
30
+ data-action="janela--dashboard#toggle"
31
+ data-janela--dashboard-key-param="<%= @pane.filter_key %>"
32
+ data-janela--dashboard-value-param="<%= value %>">
33
+ <%= value %>
34
+ </button>
35
+ <% else %>
36
+ <span><%= value %></span>
37
+ <% end %>
38
+ </td>
39
+ <td><%= number_with_delimiter(measured) %></td>
40
+ </tr>
41
+ <% end %>
42
+ </tbody>
43
+ </table>
44
+ <% end %>
45
+ <% end %>
data/config/routes.rb CHANGED
@@ -1,3 +1,9 @@
1
1
  Janela::Engine.routes.draw do
2
- resource :visual, only: :show
2
+ segment = /[a-z0-9_]+/
3
+
4
+ get "snapshots/:snapshot_id/:model/:measure(/:dimension)", to: "snapshot_panes#show", as: :snapshot_pane,
5
+ constraints: { snapshot_id: /\d+/, model: segment, measure: segment, dimension: segment }
6
+
7
+ get ":model/:measure(/:dimension)", to: "panes#show", as: :pane,
8
+ constraints: { model: segment, measure: segment, dimension: segment }
3
9
  end
@@ -0,0 +1,13 @@
1
+ class CreateJanelaSnapshots < ActiveRecord::Migration[8.0]
2
+ def change
3
+ create_table :janela_snapshots do |t|
4
+ t.string :name, null: false
5
+ t.datetime :taken_at, null: false
6
+ t.json :filters, null: false
7
+ t.json :panes, null: false
8
+ t.timestamps
9
+ end
10
+
11
+ add_index :janela_snapshots, :taken_at
12
+ end
13
+ end
@@ -111,10 +111,11 @@ resolves against the engine's routes. Blazer documents the same
111
111
 
112
112
  ## Consequences
113
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.
114
+ - The npm package's `files` glob excludes `vendor/`, which a registry
115
+ install honours. Git-based installs (`github:`, `file:`) copy the
116
+ repository tree as is, so they carry the 200 KB vendored Chart.js
117
+ that a bundler host never imports. Harmless, and gone the day the
118
+ package is published to npm.
118
119
  - An importmap host with its own `chart.js` pin keeps it. The check is
119
120
  `packages.key?("chart.js")` in the engine's `config/importmap.rb` and
120
121
  depends on the host's importmap being evaluated first, which is the