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.
@@ -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.
@@ -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: 005
52
+ Next ADR: 010
@@ -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
- def query(measure_name, by: nil, where: {}, on: nil)
21
+ # host's authorisation, e.g. on: policy_scope(Order). A time dimension
22
+ # buckets by its declared granularity unless one is given.
23
+ def query(measure_name, by: nil, where: {}, on: nil, granularity: nil, limit: nil)
24
+ measure = measure!(measure_name)
23
25
  relation = filter(on || model.all, where)
26
+ return measure.apply(relation) if by.nil?
24
27
 
25
- if by
26
- dimension = dimension!(by)
27
- relation = relation.left_joins(dimension.through) if dimension.through
28
- relation = relation.group(dimension.attribute)
29
- end
28
+ dimension = dimension!(by)
29
+ relation = relation.left_joins(dimension.through) if dimension.through
30
30
 
31
- measure!(measure_name).apply(relation)
31
+ if dimension.time?
32
+ granularity = Dimension.granularity!(granularity || dimension.granularity)
33
+ options = granularity == "week" ? { week_start: Date.beginning_of_week } : {}
34
+ buckets = relation.group_by_period(granularity, dimension.qualified_column, **options)
35
+ measure.apply(buckets).transform_keys { |bucket| dimension.label(bucket, granularity) }
36
+ else
37
+ grouped = relation.group(dimension.attribute).order(Arel.sql("#{measure.sql_alias} DESC"))
38
+ grouped = grouped.limit(limit!(limit)) if limit
39
+ measure.apply(grouped)
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.name.to_s }
54
+ dimensions.values.reject(&:through).map { |dimension| dimension.column.to_s }
40
55
  end
41
56
 
42
57
  def ransackable_associations
@@ -1,21 +1,56 @@
1
1
  module Janela
2
2
  class Dimension
3
- attr_reader :name, :model, :through
3
+ GRANULARITIES = %w[hour day week month quarter year].freeze
4
4
 
5
- def initialize(name, model:, through: nil)
5
+ 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[name]
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}_#{name}" : name.to_s
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