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 +4 -4
- data/CHANGELOG.md +25 -0
- data/README.md +62 -11
- data/app/assets/javascripts/janela/chart_controller.js +9 -3
- data/app/assets/javascripts/janela/dashboard_controller.js +27 -9
- data/app/controllers/janela/{visuals_controller.rb → panes_controller.rb} +6 -4
- data/app/controllers/janela/snapshot_panes_controller.rb +21 -0
- data/app/helpers/janela/dashboard_helper.rb +38 -5
- data/app/jobs/janela/snapshot_job.rb +18 -0
- data/app/models/janela/pane.rb +118 -0
- data/app/models/janela/snapshot.rb +51 -0
- data/app/views/janela/panes/show.html.erb +45 -0
- data/config/routes.rb +7 -1
- data/db/migrate/20260915000001_create_janela_snapshots.rb +13 -0
- data/docs/decisions/004-charts-and-javascript-delivery.md +5 -4
- data/docs/decisions/005-pane-urls-and-mount-path.md +136 -0
- data/docs/decisions/006-time-dimensions-with-groupdate.md +93 -0
- data/docs/decisions/007-ordering-and-limits.md +74 -0
- data/docs/decisions/008-dashboard-filters-in-the-page-url.md +68 -0
- data/docs/decisions/009-snapshots.md +141 -0
- data/docs/decisions/INDEX.md +14 -6
- data/lib/janela/definition.rb +26 -11
- data/lib/janela/dimension.rb +39 -4
- data/lib/janela/measure.rb +6 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +10 -10
- metadata +28 -5
- data/Rakefile +0 -23
- data/app/models/janela/visual.rb +0 -65
- data/app/views/janela/visuals/show.html.erb +0 -36
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-15
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 001, ADR 002, ADR 003, ADR 004
|
|
5
|
+
Triggers:
|
|
6
|
+
- changing the URL of a pane or how a dashboard frame addresses one
|
|
7
|
+
- mounting the engine anywhere other than the default, or with as:
|
|
8
|
+
- renaming a model that has a janela block
|
|
9
|
+
- adding a renderer, a parameter or a response format to panes
|
|
10
|
+
- adding a second dimension, a sort or a limit to a pane
|
|
11
|
+
- anything that turns a pane into a shareable link
|
|
12
|
+
Topics: routes, urls, mount, panes, naming, public-api
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# ADR 005: Pane URLs and the Mount Path
|
|
16
|
+
|
|
17
|
+
## Context
|
|
18
|
+
|
|
19
|
+
A dashboard is a window onto a model, and each visual in it is one
|
|
20
|
+
pane of that window. Until now a pane was addressed as
|
|
21
|
+
`/janela/visual?model=Order&measure=revenue&by=status&as=bar`: the
|
|
22
|
+
engine's own name in the path, and every identifying fact spelled out
|
|
23
|
+
as a query parameter in whatever order the helper happened to emit
|
|
24
|
+
them. That URL was an implementation detail that leaked, not a design.
|
|
25
|
+
|
|
26
|
+
Two things were wrong with it. The path segment `janela` is the
|
|
27
|
+
project's name, not a word a host application would choose for its
|
|
28
|
+
own reports, and the documentation and dummy application made it look
|
|
29
|
+
mandatory. And a pane had no address a person would want to bookmark,
|
|
30
|
+
share, or open on its own, even though the controller already rendered
|
|
31
|
+
one perfectly well when hit directly.
|
|
32
|
+
|
|
33
|
+
A related bug: the `janela_visual` helper built its frame `src` through
|
|
34
|
+
the `janela` route proxy, so mounting the engine with a different `as:`
|
|
35
|
+
broke every dashboard (#13).
|
|
36
|
+
|
|
37
|
+
## Decision
|
|
38
|
+
|
|
39
|
+
**The mount path is the host's, chosen in `config/routes.rb`.**
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
mount Janela::Engine => "/dashboards"
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
This is the one obvious way Rails engines are placed, it is what
|
|
46
|
+
Blazer does, and a separate `Janela.configure` setting for the same
|
|
47
|
+
fact would duplicate it. Documentation and the dummy application use
|
|
48
|
+
`/dashboards`, and say plainly that the path is whatever the host
|
|
49
|
+
wants. The helper finds the engine's mount by looking it up in the
|
|
50
|
+
host's route table rather than assuming a proxy name, so `as:` works
|
|
51
|
+
and #13 is closed.
|
|
52
|
+
|
|
53
|
+
**A pane's URL is the sentence an analyst would say, with the small
|
|
54
|
+
words replaced by URL syntax.**
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
/dashboards/orders/revenue orders revenue
|
|
58
|
+
/dashboards/orders/revenue/status orders revenue by status
|
|
59
|
+
/dashboards/orders/revenue/status?as=bar ... as a bar chart
|
|
60
|
+
/dashboards/orders/revenue/region?q[status_eq]=paid
|
|
61
|
+
orders revenue by region where status is paid
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Path: the model's route key, the measure, and optionally the
|
|
65
|
+
dimension. Query: `as` for the renderer (default `table`), `q` for
|
|
66
|
+
Ransack filters exactly as ADR 002 defined them. One route serves both
|
|
67
|
+
uses: a Turbo Frame in a dashboard loads it, and a person opens it
|
|
68
|
+
directly and gets that pane alone inside the host's layout.
|
|
69
|
+
|
|
70
|
+
The naming convention, recorded so future URL decisions start from it:
|
|
71
|
+
|
|
72
|
+
| An analyst says | The URL says |
|
|
73
|
+
|---------------------|-------------------------|
|
|
74
|
+
| *by* | `/` |
|
|
75
|
+
| *where*, *for*, *only* | `?q[...]` |
|
|
76
|
+
| *as a bar chart* | `?as=bar` |
|
|
77
|
+
| nothing after the measure | no dimension segment: the total |
|
|
78
|
+
|
|
79
|
+
There is no literal `by` segment. It carried nothing the router
|
|
80
|
+
needed, since segment count and the model's declared dimensions
|
|
81
|
+
already disambiguate, and it kept `/orders/revenue` from being a
|
|
82
|
+
complete sentence. Rails paths are nouns without prepositions;
|
|
83
|
+
`/posts/1/comments`, not `/posts/1/with/comments`.
|
|
84
|
+
|
|
85
|
+
**A pane with no dimension is a single value.** `/orders/revenue` is
|
|
86
|
+
the measure's total under the current filters, rendered as one number.
|
|
87
|
+
It is the KPI tile every dashboard has, and it falls out of the
|
|
88
|
+
grammar rather than being a separate feature. It is a pane like any
|
|
89
|
+
other: it lives in a Turbo Frame, it re-scopes when other panes are
|
|
90
|
+
clicked, and it has nothing of its own to click.
|
|
91
|
+
|
|
92
|
+
The renderer is a query parameter rather than a format extension
|
|
93
|
+
(`status.bar`) because Rails treats an extension as a MIME format and
|
|
94
|
+
`bar` is not one; it would also spend the extension that a future
|
|
95
|
+
`.json` response of the same pane's data should have.
|
|
96
|
+
|
|
97
|
+
The model appears as its `model_name.route_key` (`orders`,
|
|
98
|
+
`sales_orders`), the same identifier the host's own resource routes
|
|
99
|
+
use. The registry from ADR 003 keys on route key instead of class
|
|
100
|
+
name and remains the allowlist: a request for a key no model declared
|
|
101
|
+
is refused before anything is constantized.
|
|
102
|
+
|
|
103
|
+
**The concept is a pane, and the code says so.** `Janela::Visual`
|
|
104
|
+
becomes `Janela::Pane`, `VisualsController` becomes `PanesController`,
|
|
105
|
+
`janela_visual` becomes `janela_pane`, and the CSS hooks become
|
|
106
|
+
`janela-pane`, `janela-chart`, `janela-value` and `janela-empty`.
|
|
107
|
+
"Visual" was a placeholder borrowed from BI vendors; "pane" is the
|
|
108
|
+
word that fits a project named for a window, and it is the word its
|
|
109
|
+
author reached for unprompted.
|
|
110
|
+
|
|
111
|
+
## Consequences
|
|
112
|
+
|
|
113
|
+
- This breaks the 0.1.0 API: helper name, class names, frame ids, CSS
|
|
114
|
+
classes and the route. Acceptable now, while the only installation
|
|
115
|
+
is the author's own, and impossible to do cheaply later. Ships as
|
|
116
|
+
0.2.0 with the CHANGELOG saying exactly what to rename.
|
|
117
|
+
- A model's route key becomes part of a public URL. Renaming the model
|
|
118
|
+
changes the URL, which is how every other Rails resource already
|
|
119
|
+
behaves.
|
|
120
|
+
- A pane opened directly has no dashboard controller around it, so
|
|
121
|
+
clicking a value does nothing there. That is correct: the click
|
|
122
|
+
would only ever filter other panes, and there are none. Filters in
|
|
123
|
+
the query string still apply, so a filtered pane is a shareable
|
|
124
|
+
link. This delivers #1 for a single pane; a whole dashboard's
|
|
125
|
+
filter state in the URL remains open.
|
|
126
|
+
- The grammar has one open slot: a second path segment after the
|
|
127
|
+
dimension. Whether that means a second dimension (a matrix), a sort,
|
|
128
|
+
or is refused is a decision for the ADR that adds it, and it should
|
|
129
|
+
start from the naming table above: an analyst would say "by status
|
|
130
|
+
*and* region", which suggests another `/`.
|
|
131
|
+
- A time dimension with a granularity (#3) is still "by placed_on";
|
|
132
|
+
the granularity is a modifier, so it is a query parameter, not a new
|
|
133
|
+
word in the path.
|
|
134
|
+
- Looking up the mount in the host's route table means the helper
|
|
135
|
+
finds the first mount of the engine. Mounting Janela twice is
|
|
136
|
+
unsupported and not a goal.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-15
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 002, ADR 005
|
|
5
|
+
Triggers:
|
|
6
|
+
- grouping a measure by a date or time column
|
|
7
|
+
- adding a granularity, a time zone rule or a week start
|
|
8
|
+
- making a time pane clickable or adding drill-down
|
|
9
|
+
- adding a dependency to the query path
|
|
10
|
+
Topics: dsl, time, granularity, groupdate, dependencies, charts
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 006: Time Dimensions with Groupdate
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
A dashboard without time is thin. "Revenue by status" answers one
|
|
18
|
+
question; "revenue per month" answers the one people ask first. ADR 002
|
|
19
|
+
built dimensions as columns grouped as they are, which is right for
|
|
20
|
+
categories and wrong for dates: grouping by a raw `placed_on` yields one
|
|
21
|
+
row per day with gaps wherever nothing happened, in whatever order the
|
|
22
|
+
database returns them, in the database's time zone.
|
|
23
|
+
|
|
24
|
+
Bucketing time correctly is harder than it looks. A month boundary
|
|
25
|
+
depends on the viewer's time zone, a week depends on which day starts
|
|
26
|
+
it, an empty bucket must still appear so a chart does not silently skip
|
|
27
|
+
it, and every database spells `date_trunc` differently. ADR 002 named
|
|
28
|
+
Groupdate as the conventional answer and deferred the decision until
|
|
29
|
+
the work arrived.
|
|
30
|
+
|
|
31
|
+
## Decision
|
|
32
|
+
|
|
33
|
+
**A time dimension declares a default granularity.**
|
|
34
|
+
|
|
35
|
+
```ruby
|
|
36
|
+
dimension :placed_on, granularity: :month
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Granularity is one of `hour`, `day`, `week`, `month`, `quarter`,
|
|
40
|
+
`year`. A dimension with a granularity is a time dimension; one without
|
|
41
|
+
is a category, exactly as before.
|
|
42
|
+
|
|
43
|
+
**Granularity is a modifier, so it is a query parameter.** Following
|
|
44
|
+
ADR 005's naming table, an analyst says "revenue by placed_on, per
|
|
45
|
+
week": the dimension is the path segment, the granularity is
|
|
46
|
+
`?granularity=week`. The dimension's declared value is the default.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
/dashboards/orders/revenue/placed_on
|
|
50
|
+
/dashboards/orders/revenue/placed_on?granularity=week&as=line
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Groupdate does the bucketing.** `group_by_period(granularity, column)`
|
|
54
|
+
handles the time zone, the week start, gap filling with zeros and the
|
|
55
|
+
database differences. Janela adds a runtime dependency on it rather
|
|
56
|
+
than reimplementing any of that. The week starts on the host's
|
|
57
|
+
`Date.beginning_of_week`, which is Monday unless the host says
|
|
58
|
+
otherwise. Buckets are labelled in Ruby after the query
|
|
59
|
+
(`2026-09-01`, `Sep 2026`, `Q3 2026`, `2026`) so labels do not depend
|
|
60
|
+
on the database.
|
|
61
|
+
|
|
62
|
+
**Charts gain a `line` renderer.** A time series wants a line; the
|
|
63
|
+
renderer whitelist becomes `table`, `bar`, `line`. Nothing else about
|
|
64
|
+
charts changes.
|
|
65
|
+
|
|
66
|
+
**Time panes are not yet click sources.** Clicking a category value
|
|
67
|
+
adds one Ransack condition, `status_eq=paid`. Clicking a month means
|
|
68
|
+
two, `placed_on_gteq` and `placed_on_lt`, and the dashboard's filter
|
|
69
|
+
model is a single key and value per toggle. Rather than half-build
|
|
70
|
+
drill-down today, a time pane's values render as plain text and its
|
|
71
|
+
chart ignores clicks. Time panes still re-scope when any other pane is
|
|
72
|
+
clicked. Drill-down, narrowing granularity by clicking a bucket, is
|
|
73
|
+
the natural next decision and gets its own ADR.
|
|
74
|
+
|
|
75
|
+
## Consequences
|
|
76
|
+
|
|
77
|
+
- Groupdate is a runtime dependency. It is the conventional Rails
|
|
78
|
+
answer, it is small, and reimplementing time-zone-correct bucketing
|
|
79
|
+
across three databases would be far more code than Janela should
|
|
80
|
+
carry.
|
|
81
|
+
- Time buckets are gap-filled, so a chart over a quiet period shows
|
|
82
|
+
the quiet rather than compressing it away. A table over a long range
|
|
83
|
+
gets long; the ordering and limit decision (planned next) applies to
|
|
84
|
+
time panes as to any other.
|
|
85
|
+
- Time zone follows `Time.zone`, which is what a Rails host already
|
|
86
|
+
configured. SQLite does not support time zone conversion, so a host
|
|
87
|
+
on SQLite gets UTC buckets; the dummy application runs in UTC.
|
|
88
|
+
- A declared `placed_on` dimension is Ransack-allowlisted like any
|
|
89
|
+
other, so hosts can pass `placed_on_gteq` and `placed_on_lt` as
|
|
90
|
+
filters today even though clicking does not generate them yet.
|
|
91
|
+
- The naming table in ADR 005 predicted this shape (granularity as a
|
|
92
|
+
modifier, not a path word) before the feature existed, which is the
|
|
93
|
+
point of having recorded it.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-15
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 005, ADR 006
|
|
5
|
+
Triggers:
|
|
6
|
+
- changing how a pane's rows or bars are ordered
|
|
7
|
+
- adding a limit, a "top N", an "other" bucket or paging to a pane
|
|
8
|
+
- adding a sort direction or sorting by something other than the measure
|
|
9
|
+
- a pane rendering too many rows or bars to read
|
|
10
|
+
Topics: ordering, limit, panes, urls, query-layer
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 007: Ordering and Limits
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
A category pane returned its groups in whatever order the database
|
|
18
|
+
produced them, and all of them. In the dummy application that is three
|
|
19
|
+
statuses. In a real host it was a hundred projects in an arbitrary
|
|
20
|
+
order, which is unreadable as a table and meaningless as a bar chart.
|
|
21
|
+
Every BI tool sorts a categorical breakdown by its measure and offers
|
|
22
|
+
"top N"; a pane that does neither is not finished.
|
|
23
|
+
|
|
24
|
+
ADR 005 deliberately left this out of the URL grammar and asked that it
|
|
25
|
+
be decided rather than added quietly.
|
|
26
|
+
|
|
27
|
+
## Decision
|
|
28
|
+
|
|
29
|
+
**A category pane is ordered by its measure, largest first.** Always,
|
|
30
|
+
in SQL, using the alias ActiveRecord already gives the aggregate
|
|
31
|
+
(`sum_amount`, `count_all`, `average_amount`). There is no option to
|
|
32
|
+
turn this off or sort another way: one obvious ordering, and the one
|
|
33
|
+
every analyst expects.
|
|
34
|
+
|
|
35
|
+
**`limit` is a modifier, so it is a query parameter.** Following ADR
|
|
36
|
+
005's naming table, an analyst says "top ten customers by revenue":
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
/dashboards/orders/revenue/customer?limit=10
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`janela_pane Order, :revenue, by: :customer, limit: 10` emits the same.
|
|
43
|
+
The limit is applied in SQL after the ordering, so the database does
|
|
44
|
+
the work and the pane receives ten rows, not a thousand trimmed in
|
|
45
|
+
Ruby. It must be an integer from 1 to 1000. A pane with a limit has it
|
|
46
|
+
in its frame id, so "top five" and "all" of the same breakdown can
|
|
47
|
+
share a page.
|
|
48
|
+
|
|
49
|
+
**Time panes are exempt.** A time series is ordered by time and shows
|
|
50
|
+
its whole range; ordering it by value would destroy it, and "the top
|
|
51
|
+
ten days" is a different question from "revenue per day". A limit on a
|
|
52
|
+
time pane is ignored. Narrowing a time range is a filter
|
|
53
|
+
(`placed_on_gteq`), which ADR 006 already allows.
|
|
54
|
+
|
|
55
|
+
**Not decided here, on purpose:** an "other" bucket that sums what the
|
|
56
|
+
limit cut off, ascending order, sorting by label, and paging. Each is
|
|
57
|
+
plausible; none is needed by the first host. They belong to the ADR
|
|
58
|
+
that needs them and should start from the naming table: an analyst
|
|
59
|
+
says "bottom five" or "the rest".
|
|
60
|
+
|
|
61
|
+
## Consequences
|
|
62
|
+
|
|
63
|
+
- Every existing category pane changes order to measure-descending.
|
|
64
|
+
That is a visible change and the right one; the old order was
|
|
65
|
+
accidental.
|
|
66
|
+
- Ordering by the aggregate alias relies on ActiveRecord's naming of
|
|
67
|
+
grouped calculation columns, which has been stable across major
|
|
68
|
+
versions but is not a documented contract. If it changes, the test
|
|
69
|
+
that asserts the order will say so.
|
|
70
|
+
- The naming table in ADR 005 gains a row: *top N* is `?limit=N`.
|
|
71
|
+
- The unbounded-query concern in issue #8 is now bounded by hosts that
|
|
72
|
+
pass a limit. It remains unbounded for hosts that do not, and the
|
|
73
|
+
question of whether Janela should impose a ceiling stays with that
|
|
74
|
+
issue.
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-15
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 003, ADR 005
|
|
5
|
+
Triggers:
|
|
6
|
+
- changing where dashboard filter state lives or how it is serialised
|
|
7
|
+
- making a dashboard, not just a pane, shareable or bookmarkable
|
|
8
|
+
- touching browser history from the dashboard controller
|
|
9
|
+
- rendering a dashboard page that arrives with filters already applied
|
|
10
|
+
Topics: cross-filtering, urls, stimulus, turbo, progressive-enhancement
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 008: Dashboard Filters in the Page URL
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
ADR 003 kept a dashboard's filter state in the Stimulus controller and
|
|
18
|
+
in each Turbo Frame's `src`, and named the cost: a reload lost every
|
|
19
|
+
filter, and a filtered dashboard could not be shared as a link. ADR 005
|
|
20
|
+
gave each pane a URL that carries its filters, which made a single
|
|
21
|
+
pane shareable but not the dashboard around it.
|
|
22
|
+
|
|
23
|
+
The host application's page URL is the natural home. It is what a
|
|
24
|
+
browser bookmarks, what a person pastes into a message, and what Rails
|
|
25
|
+
already parses into `params[:q]` on the way in.
|
|
26
|
+
|
|
27
|
+
## Decision
|
|
28
|
+
|
|
29
|
+
**The page URL carries the dashboard's filters as `q[...]`, the same
|
|
30
|
+
Ransack parameters every pane already accepts.**
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
/reports/orders?q[customer_region_eq]=APAC&q[status_eq]=paid
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**The server renders the initial state.** `janela_dashboard` reads
|
|
37
|
+
`params[:q]` from the page request and sets it as the controller's
|
|
38
|
+
starting filters; `janela_pane` bakes the same filters into each
|
|
39
|
+
frame's initial `src`. A dashboard opened from a shared link is
|
|
40
|
+
therefore correct before any JavaScript runs, and the controller's
|
|
41
|
+
first pass over the frames changes nothing, so nothing loads twice.
|
|
42
|
+
|
|
43
|
+
**The controller keeps the URL current.** Whenever filters change it
|
|
44
|
+
rewrites the page URL's `q[...]` parameters with `history.replaceState`,
|
|
45
|
+
preserving whatever else is in the query string and Turbo's own
|
|
46
|
+
history state. Filter keys are written in sorted order on both sides
|
|
47
|
+
so the server and the browser serialise identically.
|
|
48
|
+
|
|
49
|
+
**Replace, not push.** Every click does not become a history entry.
|
|
50
|
+
The back button leaves the dashboard, as it does in every BI tool;
|
|
51
|
+
undoing a filter is clicking it again or clearing. Pushing each toggle
|
|
52
|
+
would trap the user in their own click history.
|
|
53
|
+
|
|
54
|
+
## Consequences
|
|
55
|
+
|
|
56
|
+
- A reload keeps the filters. A pasted dashboard URL opens filtered.
|
|
57
|
+
Issue #1 is closed.
|
|
58
|
+
- A pane URL and a dashboard URL now share the same `q` vocabulary, so
|
|
59
|
+
a filter copied from one works in the other. That is the payoff of
|
|
60
|
+
ADR 002 making Ransack parameters the contract rather than a Janela
|
|
61
|
+
vocabulary.
|
|
62
|
+
- The host page's other query parameters survive untouched. Janela
|
|
63
|
+
only owns the `q` namespace on that URL, which a host using Ransack
|
|
64
|
+
for a search form on the same page would also want. Documented: put
|
|
65
|
+
a dashboard and a Ransack search form on different pages, or expect
|
|
66
|
+
them to share filters.
|
|
67
|
+
- Two dashboards on one page would share one URL and one `q`. Not
|
|
68
|
+
supported and not a goal; one dashboard per page is the shape.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-15
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 001, ADR 002, ADR 005, ADR 008
|
|
5
|
+
Triggers:
|
|
6
|
+
- publishing a dashboard or pane for an audience that must not see live data or slicers
|
|
7
|
+
- adding a database table, migration or model to the engine
|
|
8
|
+
- scheduling anything with ActiveJob
|
|
9
|
+
- changing what a snapshot stores or how a stored pane is addressed
|
|
10
|
+
- anything called static mode, publish, report history or as-of
|
|
11
|
+
Topics: snapshots, static-mode, publishing, persistence, activejob, urls
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 009: Snapshots
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
The founding brief describes two modes over one definition: a dynamic
|
|
19
|
+
dashboard for analysts, with slicers and cross-filtering, and the same
|
|
20
|
+
dashboard published as a locked, static view for an audience that
|
|
21
|
+
should see exactly what was signed off and nothing else. Everything
|
|
22
|
+
built so far is the dynamic mode. This ADR is the static one.
|
|
23
|
+
|
|
24
|
+
The brief also settled the shape in principle: publishing spawns a
|
|
25
|
+
frozen record through ActiveJob rather than toggling the live
|
|
26
|
+
dashboard, so an analyst editing tomorrow cannot silently change what a
|
|
27
|
+
client saw today. What remained open was what exactly is frozen, how a
|
|
28
|
+
frozen pane is addressed, and how much new surface the engine grows.
|
|
29
|
+
|
|
30
|
+
This is the first thing Janela writes to a database. Until now the
|
|
31
|
+
engine was read-only over the host's own tables.
|
|
32
|
+
|
|
33
|
+
The incumbent's vocabulary is a useful check. Power BI Report Server
|
|
34
|
+
calls this a report snapshot, "a report that contains layout
|
|
35
|
+
information and query results retrieved at a specific point in time",
|
|
36
|
+
accumulates them as report history, and keeps saved filter state
|
|
37
|
+
(bookmarks) as a separate concept. That separation matches ADR 008.
|
|
38
|
+
|
|
39
|
+
## Decision
|
|
40
|
+
|
|
41
|
+
**A snapshot freezes results, not HTML.** Each pane's result, the
|
|
42
|
+
label-to-value hash or the single value, is stored as JSON together
|
|
43
|
+
with what produced it: model route key, measure, dimension,
|
|
44
|
+
granularity, limit. Rendering stays live code, so a snapshot taken
|
|
45
|
+
today is drawn with tomorrow's chart improvements, stays a few
|
|
46
|
+
kilobytes, and cannot freeze a rendering bug into history. The
|
|
47
|
+
renderer is not stored; a stored pane can be shown as a table or a
|
|
48
|
+
chart by whoever renders it, because the renderer is a viewing choice,
|
|
49
|
+
not part of the data.
|
|
50
|
+
|
|
51
|
+
**One row is one taking.** "Publish this dashboard as of today" means
|
|
52
|
+
several panes frozen at the same instant under the same filters, so a
|
|
53
|
+
`Janela::Snapshot` row holds a name, `taken_at`, the filters, and an
|
|
54
|
+
array of pane results. There is no Dashboard model and no dashboard
|
|
55
|
+
DSL: the host's page is the dashboard, and the host names the panes to
|
|
56
|
+
freeze. If the host renders its live page from the same list it passes
|
|
57
|
+
here, nothing is declared twice.
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
Janela::Snapshot.take(name: "September 2026", filters: { status_eq: "paid" }) do |take|
|
|
61
|
+
take.pane Order, :revenue, on: policy_scope(Order)
|
|
62
|
+
take.pane Order, :revenue, by: :status, on: policy_scope(Order)
|
|
63
|
+
take.pane Order, :revenue, by: :placed_on, granularity: :week
|
|
64
|
+
end
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`on:` is per pane, since a snapshot may span models and a relation
|
|
68
|
+
cannot be serialised. It defaults to the model's `all`.
|
|
69
|
+
|
|
70
|
+
**A stored pane has a URL, and it says as of.** Following ADR 005's
|
|
71
|
+
naming table, an analyst says "orders revenue by status *as of* the
|
|
72
|
+
September snapshot":
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
/dashboards/snapshots/42/orders/revenue/status
|
|
76
|
+
/dashboards/snapshots/42/orders/revenue/status?as=bar
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Same grammar as a live pane with a `snapshots/:id/` prefix. A pane the
|
|
80
|
+
snapshot does not contain is a 404, and `q` is ignored: the filters
|
|
81
|
+
were fixed when it was taken. The naming table gains a row: *as of* is
|
|
82
|
+
`/snapshots/:id/`.
|
|
83
|
+
|
|
84
|
+
**Static mode is what a stored pane is, not a switch.** It has no
|
|
85
|
+
filter key, no click action, no dashboard controller, no `q`. Tables
|
|
86
|
+
render values without buttons; charts ignore clicks. That is the
|
|
87
|
+
"no slicers, no surprises" mode from the README, and it costs nothing
|
|
88
|
+
new because time panes already taught the code to be non-clickable.
|
|
89
|
+
The host renders one with `janela_snapshot_pane snapshot, Order,
|
|
90
|
+
:revenue, by: :status, as: :bar`, the same shape as `janela_pane`.
|
|
91
|
+
|
|
92
|
+
**Taking is a Ruby method; the job is a thin wrapper.**
|
|
93
|
+
`Snapshot.take` is the API and runs wherever it is called. A
|
|
94
|
+
`Janela::SnapshotJob` wraps it for ActiveJob so a host can schedule it
|
|
95
|
+
with whatever it already uses, taking serialisable arguments (name,
|
|
96
|
+
filters, a list of pane hashes). Because a job cannot receive a
|
|
97
|
+
relation, the shipped job uses each model's default scope. A tenanted
|
|
98
|
+
host writes its own job around `take`, passing `on:` from whatever
|
|
99
|
+
identifies the tenant. This is documented rather than solved: tenancy
|
|
100
|
+
is the host's, as ADR 002 decided.
|
|
101
|
+
|
|
102
|
+
**One table, named by convention.** `janela_snapshots` with `name`,
|
|
103
|
+
`taken_at`, `filters` (JSON), `panes` (JSON) and timestamps, installed
|
|
104
|
+
with the standard `rails janela:install:migrations`. The `janela_`
|
|
105
|
+
prefix is the Rails engine convention that keeps an engine's tables
|
|
106
|
+
from colliding with the host's, as `active_storage_blobs` and
|
|
107
|
+
`solid_queue_jobs` do. It is not configurable: the host never types
|
|
108
|
+
the table name, only `Janela::Snapshot`, and a knob for a name nobody
|
|
109
|
+
types is exactly what ADR 001 declines to add. Snapshots are
|
|
110
|
+
immutable; deleting them is host policy.
|
|
111
|
+
|
|
112
|
+
**Who may see a snapshot is the host's decision.** Snapshot panes are
|
|
113
|
+
served through the same controllers, so the host's authentication
|
|
114
|
+
applies by default. An external audience with no accounts is the case
|
|
115
|
+
that motivates snapshots, and it is served by the host building its
|
|
116
|
+
own page over `janela_snapshot_pane` behind whatever share tokens or
|
|
117
|
+
signed links it already trusts. Janela stays out of access control,
|
|
118
|
+
per ADR 002 and ADR 004.
|
|
119
|
+
|
|
120
|
+
## Consequences
|
|
121
|
+
|
|
122
|
+
- Janela gains a migration, a model and a job: its first write
|
|
123
|
+
surface. Hosts that never take a snapshot never need the migration;
|
|
124
|
+
live dashboards remain read-only over the host's tables.
|
|
125
|
+
- Result JSON is small for category panes and bounded by `limit`
|
|
126
|
+
(ADR 007). A time pane over a long range at fine granularity is the
|
|
127
|
+
one way a snapshot gets large, and that is visible at take time.
|
|
128
|
+
- Storing the renderer's inputs rather than its output means a
|
|
129
|
+
snapshot cannot reproduce an old rendering exactly. Accepted: the
|
|
130
|
+
numbers are the record, not the pixels.
|
|
131
|
+
- Per-pane `on:` is more typing than one scope for the whole taking.
|
|
132
|
+
It is also the only honest option once a snapshot spans models, and
|
|
133
|
+
it keeps the tenancy question where ADR 002 put it.
|
|
134
|
+
- The shipped job is deliberately naive. If most hosts turn out to
|
|
135
|
+
write their own, the job should be removed rather than grown.
|
|
136
|
+
- Snapshots are additive to the public API. Whether they ship in the
|
|
137
|
+
next release or the one after is a release decision, not a design
|
|
138
|
+
one.
|
|
139
|
+
- Not decided here: comparing two snapshots, an "other" bucket for
|
|
140
|
+
limited panes, retention, or an index page listing snapshots. Each
|
|
141
|
+
is its own ADR if a host needs it.
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -22,11 +22,14 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
22
22
|
|-------|------|
|
|
23
23
|
| **Vision, scope, forkability** | 001 |
|
|
24
24
|
| **Open-source & host-decoupling** | 001 |
|
|
25
|
-
| **DSL & query layer** | 002 |
|
|
26
|
-
| **Dependencies** | 002, 003, 004 |
|
|
27
|
-
| **Authorisation** | 002, 003, 004 |
|
|
28
|
-
| **Cross-filtering & Hotwire** | 003, 004 |
|
|
29
|
-
| **JavaScript delivery & charts** | 004 |
|
|
25
|
+
| **DSL & query layer** | 002, 006, 007 |
|
|
26
|
+
| **Dependencies** | 002, 003, 004, 006 |
|
|
27
|
+
| **Authorisation** | 002, 003, 004, 009 |
|
|
28
|
+
| **Cross-filtering & Hotwire** | 003, 004, 005, 008 |
|
|
29
|
+
| **JavaScript delivery & charts** | 004, 006 |
|
|
30
|
+
| **Time dimensions** | 006 |
|
|
31
|
+
| **Routes, URLs & naming** | 005, 007, 008, 009 |
|
|
32
|
+
| **Snapshots & publishing** | 009 |
|
|
30
33
|
| **Security** | 003 |
|
|
31
34
|
| **Testing** | 003 |
|
|
32
35
|
|
|
@@ -38,7 +41,12 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
38
41
|
| 002 | Measures and Dimensions over Ransack | 2026-09-13 | Accepted |
|
|
39
42
|
| 003 | Cross-filtering with Turbo Frames | 2026-09-13 | Accepted |
|
|
40
43
|
| 004 | Charts and JavaScript Delivery | 2026-09-15 | Accepted |
|
|
44
|
+
| 005 | Pane URLs and the Mount Path | 2026-09-15 | Accepted |
|
|
45
|
+
| 006 | Time Dimensions with Groupdate | 2026-09-15 | Accepted |
|
|
46
|
+
| 007 | Ordering and Limits | 2026-09-15 | Accepted |
|
|
47
|
+
| 008 | Dashboard Filters in the Page URL | 2026-09-15 | Accepted |
|
|
48
|
+
| 009 | Snapshots | 2026-09-15 | Accepted |
|
|
41
49
|
|
|
42
50
|
## Next number
|
|
43
51
|
|
|
44
|
-
Next ADR:
|
|
52
|
+
Next ADR: 010
|
data/lib/janela/definition.rb
CHANGED
|
@@ -12,31 +12,46 @@ module Janela
|
|
|
12
12
|
measures[name] = Measure.build(name, **aggregate)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def dimension(name, through: nil)
|
|
16
|
-
dimensions[name] = Dimension.new(name, model: model, through: through)
|
|
15
|
+
def dimension(name, through: nil, column: nil, granularity: nil)
|
|
16
|
+
dimensions[name] = Dimension.new(name, model: model, through: through, column: column, granularity: granularity)
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
# Filters are Ransack params, so a host can pass params[:q] straight
|
|
20
20
|
# through from a search_form_for slicer. Scope with on: to respect the
|
|
21
|
-
# host's authorisation, e.g. on: policy_scope(Order).
|
|
22
|
-
|
|
21
|
+
# host's authorisation, e.g. on: policy_scope(Order). A time dimension
|
|
22
|
+
# buckets by its declared granularity unless one is given.
|
|
23
|
+
def query(measure_name, by: nil, where: {}, on: nil, granularity: nil, limit: nil)
|
|
24
|
+
measure = measure!(measure_name)
|
|
23
25
|
relation = filter(on || model.all, where)
|
|
26
|
+
return measure.apply(relation) if by.nil?
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
relation = relation.left_joins(dimension.through) if dimension.through
|
|
28
|
-
relation = relation.group(dimension.attribute)
|
|
29
|
-
end
|
|
28
|
+
dimension = dimension!(by)
|
|
29
|
+
relation = relation.left_joins(dimension.through) if dimension.through
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
if dimension.time?
|
|
32
|
+
granularity = Dimension.granularity!(granularity || dimension.granularity)
|
|
33
|
+
options = granularity == "week" ? { week_start: Date.beginning_of_week } : {}
|
|
34
|
+
buckets = relation.group_by_period(granularity, dimension.qualified_column, **options)
|
|
35
|
+
measure.apply(buckets).transform_keys { |bucket| dimension.label(bucket, granularity) }
|
|
36
|
+
else
|
|
37
|
+
grouped = relation.group(dimension.attribute).order(Arel.sql("#{measure.sql_alias} DESC"))
|
|
38
|
+
grouped = grouped.limit(limit!(limit)) if limit
|
|
39
|
+
measure.apply(grouped)
|
|
40
|
+
end
|
|
32
41
|
end
|
|
33
42
|
|
|
34
43
|
def dimension!(name)
|
|
35
44
|
dimensions.fetch(name) { raise Error, "#{model} has no janela dimension #{name.inspect}" }
|
|
36
45
|
end
|
|
37
46
|
|
|
47
|
+
def limit!(value)
|
|
48
|
+
limit = Integer(value, exception: false)
|
|
49
|
+
raise Error, "limit must be a whole number from 1 to 1000, got #{value.inspect}" unless limit&.between?(1, 1000)
|
|
50
|
+
limit
|
|
51
|
+
end
|
|
52
|
+
|
|
38
53
|
def ransackable_attributes
|
|
39
|
-
dimensions.values.reject(&:through).map { |dimension| dimension.
|
|
54
|
+
dimensions.values.reject(&:through).map { |dimension| dimension.column.to_s }
|
|
40
55
|
end
|
|
41
56
|
|
|
42
57
|
def ransackable_associations
|
data/lib/janela/dimension.rb
CHANGED
|
@@ -1,21 +1,56 @@
|
|
|
1
1
|
module Janela
|
|
2
2
|
class Dimension
|
|
3
|
-
|
|
3
|
+
GRANULARITIES = %w[hour day week month quarter year].freeze
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
LABELS = {
|
|
6
|
+
"hour" => ->(t) { t.strftime("%Y-%m-%d %H:00") },
|
|
7
|
+
"day" => ->(t) { t.strftime("%Y-%m-%d") },
|
|
8
|
+
"week" => ->(t) { t.strftime("%Y-%m-%d") },
|
|
9
|
+
"month" => ->(t) { t.strftime("%b %Y") },
|
|
10
|
+
"quarter" => ->(t) { "Q#{(t.month - 1) / 3 + 1} #{t.year}" },
|
|
11
|
+
"year" => ->(t) { t.strftime("%Y") }
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :name, :model, :through, :column, :granularity
|
|
15
|
+
|
|
16
|
+
# A dimension is named for what it means on the dashboard and reads a
|
|
17
|
+
# column that may be called something else, usually on an association:
|
|
18
|
+
# dimension :customer, through: :customer, column: :name.
|
|
19
|
+
def initialize(name, model:, through: nil, column: nil, granularity: nil)
|
|
6
20
|
@name = name
|
|
7
21
|
@model = model
|
|
8
22
|
@through = through
|
|
23
|
+
@column = (column || name).to_sym
|
|
24
|
+
@granularity = granularity&.to_s
|
|
9
25
|
|
|
10
26
|
raise Error, "#{model} has no association #{through.inspect}" if through && reflection.nil?
|
|
27
|
+
self.class.granularity!(@granularity) if @granularity
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.granularity!(value)
|
|
31
|
+
value = value.to_s
|
|
32
|
+
raise Error, "unknown granularity #{value.inspect}, use one of #{GRANULARITIES.join(', ')}" unless GRANULARITIES.include?(value)
|
|
33
|
+
value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def time?
|
|
37
|
+
!granularity.nil?
|
|
11
38
|
end
|
|
12
39
|
|
|
13
40
|
def attribute
|
|
14
|
-
klass.arel_table[
|
|
41
|
+
klass.arel_table[column]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def qualified_column
|
|
45
|
+
"#{klass.table_name}.#{column}"
|
|
15
46
|
end
|
|
16
47
|
|
|
17
48
|
def ransack_name
|
|
18
|
-
through ? "#{through}_#{
|
|
49
|
+
through ? "#{through}_#{column}" : column.to_s
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def label(bucket, granularity = self.granularity)
|
|
53
|
+
LABELS.fetch(granularity).call(bucket)
|
|
19
54
|
end
|
|
20
55
|
|
|
21
56
|
private
|