janela 0.2.1 → 0.4.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 +63 -1
- data/LICENSE.txt +1 -1
- data/README.md +244 -23
- data/UPGRADING.md +172 -0
- data/app/assets/javascripts/janela/chart_controller.js +23 -10
- data/app/assets/javascripts/janela/frame_controller.js +188 -0
- data/app/assets/javascripts/janela/vitral_controller.js +263 -0
- data/app/assets/stylesheets/janela.css +164 -0
- data/app/assets/stylesheets/vitral.css +343 -0
- data/app/controllers/janela/application_controller.rb +25 -0
- data/app/controllers/janela/frames_controller.rb +57 -0
- data/app/controllers/janela/panes_controller.rb +66 -13
- data/app/controllers/janela/queries_controller.rb +17 -0
- data/app/controllers/janela/{snapshot_panes_controller.rb → snapshot_queries_controller.rb} +7 -5
- data/app/helpers/janela/{dashboard_helper.rb → frames_helper.rb} +26 -9
- data/app/models/janela/frame.rb +28 -0
- data/app/models/janela/pane.rb +102 -99
- data/app/models/janela/query.rb +162 -0
- data/app/models/janela/snapshot.rb +5 -5
- data/app/views/janela/frames/_card.html.erb +7 -0
- data/app/views/janela/frames/_form.html.erb +23 -0
- data/app/views/janela/frames/_frame.html.erb +5 -0
- data/app/views/janela/frames/_pane.html.erb +12 -0
- data/app/views/janela/frames/edit.html.erb +25 -0
- data/app/views/janela/frames/index.html.erb +15 -0
- data/app/views/janela/frames/new.html.erb +5 -0
- data/app/views/janela/frames/show.html.erb +9 -0
- data/app/views/janela/panes/_form.html.erb +58 -0
- data/app/views/janela/panes/_row.html.erb +12 -0
- data/app/views/janela/panes/edit.html.erb +5 -0
- data/app/views/janela/panes/new.html.erb +22 -0
- data/app/views/janela/panes/show.html.erb +3 -46
- data/app/views/janela/queries/_query.html.erb +47 -0
- data/app/views/janela/queries/show.html.erb +4 -0
- data/app/views/janela/shared/_errors.html.erb +7 -0
- data/app/views/layouts/janela/application.html.erb +22 -5
- data/config/importmap.rb +2 -1
- data/config/locales/en.yml +65 -0
- data/config/routes.rb +15 -2
- data/db/migrate/20260916000001_create_janela_frames.rb +13 -0
- data/db/migrate/20260916000002_create_janela_panes.rb +22 -0
- data/docs/decisions/001-built-to-be-forked.md +4 -0
- data/docs/decisions/009-snapshots.md +5 -2
- data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +8 -4
- data/docs/decisions/012-frames-and-panes-are-data.md +166 -0
- data/docs/decisions/013-naming-and-addressing-frames.md +119 -0
- data/docs/decisions/014-corrections-before-frames-are-built.md +222 -0
- data/docs/decisions/015-how-breaking-change-is-communicated.md +114 -0
- data/docs/decisions/016-the-styling-vocabulary.md +100 -0
- data/docs/decisions/017-janela-owns-no-data-store.md +113 -0
- data/docs/decisions/018-a-table-is-the-universal-renderer.md +75 -0
- data/docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md +79 -0
- data/docs/decisions/020-formatting-belongs-to-the-measure.md +93 -0
- data/docs/decisions/021-a-check-has-a-name-a-host-can-silence.md +84 -0
- data/docs/decisions/022-host-route-helpers-work-inside-the-engine.md +104 -0
- data/docs/decisions/023-vitral-is-a-theme-not-the-stylesheet.md +86 -0
- data/docs/decisions/024-selecting-more-than-one-value.md +127 -0
- data/docs/decisions/INDEX.md +25 -7
- data/docs/multi-tenancy.md +175 -0
- data/docs/naming.md +172 -0
- data/lib/janela/definition.rb +5 -5
- data/lib/janela/doctor.rb +219 -0
- data/lib/janela/engine.rb +24 -2
- data/lib/janela/host_routes.rb +31 -0
- data/lib/janela/measure.rb +65 -4
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +24 -0
- data/lib/tasks/janela.rake +6 -0
- metadata +57 -4
- data/app/assets/javascripts/janela/dashboard_controller.js +0 -58
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c7fbc539879d1f11c15536c9305299ab9a754add8e26bb443cb582ea236823be
|
|
4
|
+
data.tar.gz: 150f1292e5c2789cb378e6438c91fd94b766e10088f4daa4aea445ba63341a1b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: a6d3e5390adf6ac5882d621abc014986f0da17cadad2bca3ba3404d9d2f0d4c744130d7163be7271ddfc70c1404250d1a9a3aa09f274d88a44be57c93c1ac8d4
|
|
7
|
+
data.tar.gz: 3d7d77e7022225e40c4c13f150818cfd646b0383400bbc59590014caa877b61894f6a1d0b4f056b4d438a1738b6c1489ead2d4ca60bb58caa9fa7554a15b8637
|
data/CHANGELOG.md
CHANGED
|
@@ -5,7 +5,67 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
-
## [
|
|
8
|
+
## [0.4.0] - 2026-09-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `docs/naming.md`, "Naming Things Is Hard": why Janela's words are uncommon but conceivable, the anatomy of the window, which words stay ordinary and why, how a host puts its own words in front of its users, and a checklist for naming anything new. Ships in the gem.
|
|
13
|
+
- The vitral lattice leans toward the pointer with its outer edge pinned, drifts at two depths as the page scrolls, and reads its lead colour from `--vitral-lattice-ink`, so the live and static lattices cannot disagree.
|
|
14
|
+
- More than one value can be selected in a dimension. Ctrl or Cmd click adds a value and takes it out again while the rest stay; a plain click still selects one and clears the dimension when it was the only one. A chart highlights every selected bar and answers the same modifier. Escape clears the frame's filters. All of it works from the keyboard, because a value is already a real button and a browser puts the same modifier on the click it makes from Enter (ADR 024, #35, #36).
|
|
15
|
+
- `vitral.css`, an optional theme that makes a dashboard a stained glass window: each pane holds one of five colours, dark leading runs between them and the light comes from behind. Separate from `janela.css` on purpose, which stays structure while this is taste (ADR 023). Link it, put `class="vitral"` on the element that carries the light, and use `vitral-pane`, `vitral-panes` and `vitral-button` on your own markup to match. Retheme it from the `--vitral-*` custom properties rather than by forking it.
|
|
16
|
+
- `Janela.theme`, naming the stylesheet Janela's own pages load on top of `janela.css`. Unset by default, so nothing changes for a host that has its own look.
|
|
17
|
+
- `janela/vitral_controller`, optional and separate again: it draws the leadlight live and every node leans toward the pointer. Reduced motion, reduced transparency and increased contrast each get a still, solid window instead.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- The copyright holder is Retail Tasker. The licence is still MIT, and earlier releases keep the notice they shipped with.
|
|
22
|
+
- A click writes Ransack's `_in` rather than `_eq`, one value or five, so there is one shape in the controller, the view and a stored snapshot. A link already shared with `_eq` keeps working and still reads as selected. `Query#selected_value` is now `selected_values` and returns an array, which matters only to a host that overrode a pane view (ADR 024).
|
|
23
|
+
- The null group is exclusive within its dimension. Ransack ands its conditions, so `(none)` together with a value asks for rows that are both null and not, and returns nothing at all. Selecting either now clears the other rather than rendering an empty dashboard that looks like a bug.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- A pane could show numbers for filters nobody had asked for. A pane's first load is lazy, so a request that began before a click could land after it, and Turbo renders whatever arrives and leaves the URL it fetched on the frame. Janela now keeps its own record of what it asked each pane for, and cancels any request for anything else before it can land, rather than trying to correct a wrong render afterwards. A page opened from a filtered link also no longer reloads every pane the moment it connects, which was both wasted work and the most common source of the stale request (#33).
|
|
28
|
+
- Janela's own pages were a dead end: nothing on them linked back to the application they belong to. They now carry one link to the host's root, when the host has one, labelled from i18n like every other word the engine renders. This was not possible before host route helpers resolved inside the engine (ADR 011, ADR 022).
|
|
29
|
+
- A host's own route helpers work inside Janela's controllers and views. `isolate_namespace` pointed every helper at the engine's routes, so host code that runs there and generates a URL raised: an authentication concern redirecting to `new_session_path`, a `rescue_from`, an `after_action`. An unauthenticated visitor got a 500 instead of a sign-in page. Janela now forwards exactly the helpers the engine does not define itself, so nothing of Janela's can be shadowed by a host route of the same name, and `main_app.` still says either unambiguously. Polymorphic `url_for(@record)` has no name to forward and still needs the prefix (ADR 022, #23).
|
|
30
|
+
- The README explained this as something to do "if your app authenticates per controller", which was wrong about the cause. The trigger is generating a URL inside the engine, whenever authentication runs.
|
|
31
|
+
|
|
32
|
+
## [0.3.0] - 2026-09-16
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- `docs/multi-tenancy.md`, shipped in the gem: what goes through the host's scope and what does not, worked wiring for Pundit, acts_as_tenant and CanCanCan, what owns a frame an analyst creates, how to prove it with a test, and the honest note that a snapshot has no owner column yet (#32).
|
|
37
|
+
- A measure declares how its number reads: `precision:`, `prefix:` and `suffix:`, applied the same way to a table cell, a single value and a chart tooltip. Precision defaults to what the schema already says, so counting rows is whole, a `decimal(10, 2)` column reads to the cent and only an average of an integer falls back to two places. Formatting is rendering, never rounding: a snapshot stores the number and reads back under whatever format is declared later (ADR 020, #25).
|
|
38
|
+
- A pane whose row asks for a chart renders as its table where no chart runtime exists, which is the engine's own pages; a host's page still draws the chart. `janela_frame(@frame, charts: false)` asks for it explicitly (ADR 018).
|
|
39
|
+
- The engine's layout loads Janela's own stylesheet, so its pages are styled on install. It still loads none of the host's assets.
|
|
40
|
+
- The analyst's editing surface: conventional Rails CRUD on frames and on panes nested under a frame, on Janela's own pages, working with nothing but HTML. Adding a pane is two steps, the first picking a model and the second offering only the measures and dimensions that model declares, because those pages run no JavaScript to refill one select from another. A pane moves with Up and Down buttons rather than a position field, and positions stay contiguous. Every action reads through the host's scope, so another tenant's frame is a 404 to change as well as to read (ADR 012, ADR 014).
|
|
41
|
+
- `janela_frame_owner`, a hook a host defines on its own `ApplicationController`. Janela assigns its return value as the owner of a frame it creates, so a host whose `policy_scope` filters frames by owner does not have the analyst's new dashboard hidden the instant it is saved. A host with no tenancy defines nothing and gets a nil owner. Duck typed, like `policy_scope` itself.
|
|
42
|
+
- Janela serves its own pages: an index of frames and a page per frame, at the mount root (`/dashboards`, `/dashboards/3`). Both read through the host's `policy_scope`, the noun in every heading comes from `Janela::Frame.model_name.human` so a host renames it in its own locale file, and the gem ships `config/locales/en.yml` with the English defaults. A host that wants a different index writes its own page over `Janela::Frame` and never routes to ours (ADR 013).
|
|
43
|
+
- A dashboard can be data. `Janela::Frame` holds a name and a grid (`columns` 1 to 12, `gap` 0 to 8), `Janela::Pane` holds one visual (`position`, `span` 1 to 12, model, measure, dimension, renderer, granularity, limit and an optional title of its own), and `janela_frame @frame` renders one. The block form is unchanged, so nothing already built has to move (ADR 012, ADR 014). Run `bin/rails janela:install:migrations && bin/rails db:migrate` for the two new tables.
|
|
44
|
+
- A pane row is rendered by its own nested route, `<mount>/:frame_id/panes/:id`, and identified in the DOM as `janela_pane_<id>`, so two rows showing the same measure by the same dimension do not share a turbo frame. The ad hoc pane grammar from ADR 005 is unchanged.
|
|
45
|
+
- A frame renders every pane inline on the first response, so a shared link is a correct dashboard before any JavaScript runs and a page load makes no request per pane. Filters in the page URL apply to every pane either way.
|
|
46
|
+
- A pane row is validated against the registry on save: the model must have a `janela` block, the measure, dimension, renderer and granularity must be declared or supported, a granularity only applies to a time dimension, and a limit is 1 to 1000. A bad row is rejected with a readable message rather than rendering as a missing pane later.
|
|
47
|
+
- `Janela::Frame belongs_to :owner, polymorphic: true, optional: true`. Janela sets nothing there and reads nothing from it; it exists so a multi tenant host's Pundit `Scope` has a column to filter on. Every lookup of a frame or a pane row goes through that scope, so another tenant's frame is a 404 (ADR 014).
|
|
48
|
+
- A stylesheet, `app/assets/stylesheets/janela.css`, which a host includes with `stylesheet_link_tag "janela"`. It defines the grid classes for every value the records allow, styles the existing `janela-pane`, `janela-chart`, `janela-value` and `janela-empty` hooks so a pane is legible on install (#15), and collapses to one column on a narrow screen. Set `--janela-space` to move the whole spacing scale. The engine never injects it into a layout it does not own (ADR 016).
|
|
49
|
+
- `bin/rails janela:doctor` reads a host application and lists what it still needs to do: identifiers from an earlier version, unregistered Stimulus controllers, a `through:` dimension whose associated model has no allowlist, an unmounted engine, a mounted engine whose tables were never migrated, a `policy_scope` that filters frames by owner where the host defines no `janela_frame_owner`, and whether anything authenticates the endpoints. Exits non-zero on an error so it can run in CI (ADR 015). Every finding names the check that produced it, such as `unauthenticated-endpoints`, and a host silences one it has judged a false alarm with `Janela.silenced_checks`. A silenced check is still named in the output every run (ADR 021).
|
|
50
|
+
- `UPGRADING.md`, shipped inside the gem, with the steps for each release that needs a host to act. The changelog says what changed; the upgrade guide says what to do.
|
|
51
|
+
|
|
52
|
+
### Changed
|
|
53
|
+
|
|
54
|
+
- Numbers render to the precision the measure means rather than to whatever the database returned. A sum of a `decimal(10, 2)` column that read as `375.0` now reads as `375.00`, and an average that read as `928.8767833333333` now reads as `928.88`. Nothing is rounded before it is stored or compared (ADR 020).
|
|
55
|
+
- Janela's own minimal layout now links the gem's own stylesheet, refining ADR 011. It still loads nothing of the host's, and still no JavaScript, because Turbo and Stimulus come from the host's bundler: Janela's own pages are therefore correct, styled, static dashboards. Panes render inline and filters in the URL apply; nothing cross-filters, and a chart pane draws nothing there. The engine also declares `janela.css` for precompilation, so a host on Sprockets serves it in production.
|
|
56
|
+
- A snapshot is read through the host's scope rather than `Snapshot.find`, so a stored pane a host's policy hides is a 404 rather than a result anyone who guesses an id can read (ADR 014).
|
|
57
|
+
- The frame controller no longer rewrites pane `src` attributes when it connects, only when the filters actually change. A pane rendered inline would otherwise be fetched again immediately and its first render thrown away.
|
|
58
|
+
- A frame or a pane a host's scope cannot see now answers with Janela's own sentence inside the requesting turbo frame, rather than the host's error page. Still a 404.
|
|
59
|
+
- Breaking rename, no behaviour change (ADR 014). A host must act on all of these:
|
|
60
|
+
- The Stimulus controller `janela--dashboard` is now `janela--frame`, and its file is `frame_controller.js`. Change `application.register("janela--dashboard", ...)` to `application.register("janela--frame", ...)`, and the import path from `@retail-tasker/janela/dashboard_controller` to `@retail-tasker/janela/frame_controller` on npm, or `janela/dashboard_controller` to `janela/frame_controller` on importmap.
|
|
61
|
+
- Any `data-action="janela--dashboard#clear"` (or `#toggle`), `data-janela--dashboard-*-param` and `data-janela--dashboard-target` in the host's own markup becomes `janela--frame`.
|
|
62
|
+
- The helper `janela_dashboard do ... end` is now `janela_frame do ... end`. `janela_pane` and `janela_snapshot_pane` are unchanged.
|
|
63
|
+
- The helper module `Janela::DashboardHelper` is now `Janela::FramesHelper`, which only matters to a host that includes or overrides it.
|
|
64
|
+
- `Janela::Pane` is now `Janela::Query`, and its `frame_id` is `turbo_frame_id`. `Pane` is reserved for a future record. Frame unqualified now means the dashboard; the DOM element is always spelled turbo frame.
|
|
65
|
+
- `Janela::PanesController` is now `Janela::QueriesController` and `Janela::SnapshotPanesController` is now `Janela::SnapshotQueriesController`, with their views at `app/views/janela/queries/`. A host that overrides the view moves its copy.
|
|
66
|
+
- `test/query_test.rb` is now `definition_query_test.rb`, since `query_test` read as a test of `Janela::Query` rather than of `Definition#query`.
|
|
67
|
+
- Unchanged on purpose: the pane URLs, the route helpers `pane_path` and `snapshot_pane_path`, the turbo frame ids, and the CSS hooks `janela-pane`, `janela-chart`, `janela-value` and `janela-empty`.
|
|
68
|
+
- The README's advice on styling a directly opened pane was wrong: it suggested pointing Janela at the host's application layout, which is the thing that raises `NameError` inside an isolated engine. It now shows a small asset-only layout instead, the pattern a real host arrived at.
|
|
9
69
|
|
|
10
70
|
## [0.2.1] - 2026-09-15
|
|
11
71
|
|
|
@@ -62,6 +122,8 @@ First alpha, installed from GitHub for testing in a single host application.
|
|
|
62
122
|
- Only models that declare a `janela` block are addressable over HTTP.
|
|
63
123
|
- ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
|
|
64
124
|
|
|
125
|
+
[0.4.0]: https://github.com/retail-tasker/janela/releases/tag/v0.4.0
|
|
126
|
+
[0.3.0]: https://github.com/retail-tasker/janela/releases/tag/v0.3.0
|
|
65
127
|
[0.2.1]: https://github.com/retail-tasker/janela/releases/tag/v0.2.1
|
|
66
128
|
[0.2.0]: https://github.com/retail-tasker/janela/releases/tag/v0.2.0
|
|
67
129
|
[0.1.0]: https://github.com/retail-tasker/janela/releases/tag/v0.1.0
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
PowerBI-style dashboards and cross-filtering slicers, native to Rails and ActiveRecord. Define a dashboard on your models and associations, get a live, sliceable view for internal use, or publish the same definition as a locked, static view for an external audience.
|
|
4
4
|
|
|
5
|
+
**[See it running](https://demo.janela.winontheshelf.com)**, with a year of demo orders. Click any value and the rest of the dashboard re-scopes. The demo is this repository's own test fixture with seed data, so what you click is what ships.
|
|
6
|
+
|
|
5
7
|
## First principle
|
|
6
8
|
|
|
7
9
|
Janela is a PowerBI-style library built on Ruby and Stimulus, meant to drop onto any Ruby on Rails application. No JS framework, no build step of its own, no separate frontend app. Just a gem you add to an existing Rails app's Gemfile and two Stimulus controllers that ship with it.
|
|
@@ -25,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It has t
|
|
|
25
27
|
|
|
26
28
|
```ruby
|
|
27
29
|
# Gemfile
|
|
28
|
-
gem "janela", "~> 0.
|
|
30
|
+
gem "janela", "~> 0.4"
|
|
29
31
|
```
|
|
30
32
|
|
|
31
33
|
```ruby
|
|
@@ -43,9 +45,9 @@ yarn add github:retail-tasker/janela # or: npm install github:retail-tasker/ja
|
|
|
43
45
|
|
|
44
46
|
```js
|
|
45
47
|
// app/javascript/controllers/index.js
|
|
46
|
-
import
|
|
48
|
+
import JanelaFrameController from "@retail-tasker/janela/frame_controller"
|
|
47
49
|
import JanelaChartController from "@retail-tasker/janela/chart_controller"
|
|
48
|
-
application.register("janela--
|
|
50
|
+
application.register("janela--frame", JanelaFrameController)
|
|
49
51
|
application.register("janela--chart", JanelaChartController)
|
|
50
52
|
```
|
|
51
53
|
|
|
@@ -53,16 +55,57 @@ The chart controller imports `chart.js`, which is a peer dependency: add `chart.
|
|
|
53
55
|
|
|
54
56
|
**With importmap-rails:**
|
|
55
57
|
|
|
56
|
-
Nothing to install. The engine pins `janela/
|
|
58
|
+
Nothing to install. The engine pins `janela/frame_controller`, `janela/chart_controller` and a vendored `chart.js` for you (your own `chart.js` pin wins if you have one). Register the controllers:
|
|
57
59
|
|
|
58
60
|
```js
|
|
59
61
|
// app/javascript/application.js
|
|
60
|
-
import
|
|
62
|
+
import JanelaFrameController from "janela/frame_controller"
|
|
61
63
|
import JanelaChartController from "janela/chart_controller"
|
|
62
|
-
application.register("janela--
|
|
64
|
+
application.register("janela--frame", JanelaFrameController)
|
|
63
65
|
application.register("janela--chart", JanelaChartController)
|
|
64
66
|
```
|
|
65
67
|
|
|
68
|
+
Include the stylesheet in whichever layout renders dashboards. It is small, it is the grid, and it is meant to be overridden:
|
|
69
|
+
|
|
70
|
+
```erb
|
|
71
|
+
<%= stylesheet_link_tag "janela" %>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Vitral, the optional theme
|
|
75
|
+
|
|
76
|
+
A *vitral* is a stained glass window, which is what it makes of a dashboard: each pane holds its own colour, dark leading runs between them, and the light comes from behind. It is a second stylesheet, not a replacement, and it is entirely optional. `janela.css` is structure and `vitral.css` is taste, because taste is the first thing you will want to change (ADR 023):
|
|
77
|
+
|
|
78
|
+
```erb
|
|
79
|
+
<%= stylesheet_link_tag "janela" %>
|
|
80
|
+
<%= stylesheet_link_tag "vitral" %>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
```erb
|
|
84
|
+
<body class="vitral">
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The class is what carries the light, so nothing is repainted until you ask. Three public classes let your own page join in: `vitral-pane` puts a sheet of the same glass on any element, `vitral-panes` on a container cycles its children through the five colours, and `vitral-button` is a control made of it. Everything else is a custom property, so `--vitral-came`, `--vitral-glass` and the five `--vitral-pane-*` hues retheme the lot from your own stylesheet without touching the gem's.
|
|
88
|
+
|
|
89
|
+
For Janela's own pages, name the theme once and the engine's layout wears it:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
# config/initializers/janela.rb
|
|
93
|
+
Janela.theme = "vitral"
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The leadlight can also answer the pointer, with every node leaning toward the cursor. That part is a Stimulus controller and therefore optional twice over, since Janela's own pages load no JavaScript at all (ADR 011):
|
|
97
|
+
|
|
98
|
+
```js
|
|
99
|
+
import VitralController from "@retail-tasker/janela/vitral_controller" // or "janela/vitral_controller" on importmap
|
|
100
|
+
application.register("vitral", VitralController)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
```erb
|
|
104
|
+
<body class="vitral" data-controller="vitral">
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Anyone who has asked for reduced motion, reduced transparency or more contrast gets a still, solid window instead. A browser with no `backdrop-filter` gets plain panels.
|
|
108
|
+
|
|
66
109
|
Requires Rails 8.0+ and Ruby 3.3+. If your app is on Rails 8.1.x with the `json` gem at 3.x, encrypted cookie reads raise inside ActiveSupport and every Turbo Frame request will 500 in the browser; pin `gem "json", "< 3"` until Rails ships the fix.
|
|
67
110
|
|
|
68
111
|
## Usage
|
|
@@ -86,6 +129,20 @@ end
|
|
|
86
129
|
|
|
87
130
|
A dimension with a `granularity` is a time dimension. Groupdate buckets it (`hour`, `day`, `week`, `month`, `quarter`, `year`), fills empty buckets with zero, and uses your app's `Time.zone` and week start. **On SQLite, buckets are UTC**, because SQLite cannot convert time zones: with a non-UTC `Time.zone` a daily bucket is shifted by your offset, and an early-morning row lands in the previous day. Coarser granularities blunt the shift without removing it. If you need local-day buckets on SQLite, store a local date column and use it as a plain dimension.
|
|
88
131
|
|
|
132
|
+
### How numbers read
|
|
133
|
+
|
|
134
|
+
A measure says what its own number means, and every renderer asks it, so a table cell, a single value and a chart tooltip cannot disagree (ADR 020):
|
|
135
|
+
|
|
136
|
+
```ruby
|
|
137
|
+
measure :revenue, sum: :amount, prefix: "$" # $1,234.50
|
|
138
|
+
measure :pass_rate, average: :score, precision: 1, suffix: "%" # 66.7%
|
|
139
|
+
measure :orders, count: true # 1,234
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
Precision defaults to what the schema already says. Counting rows has no decimal places, a `decimal(10, 2)` column reads to the cent, and summing an integer column stays whole. Declare `precision:` where the schema has nothing to say, such as averaging an integer, or where you want something else. Thousands are delimited with your app's locale.
|
|
143
|
+
|
|
144
|
+
Formatting is rendering, never rounding. The number itself reaches a snapshot and an order clause at full precision, so a snapshot taken last month reads back under a format you declare today.
|
|
145
|
+
|
|
89
146
|
Then query them:
|
|
90
147
|
|
|
91
148
|
```ruby
|
|
@@ -120,11 +177,11 @@ end
|
|
|
120
177
|
|
|
121
178
|
### Dashboards
|
|
122
179
|
|
|
123
|
-
Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one re-scopes the others:
|
|
180
|
+
Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one re-scopes the others. `janela_frame` wraps them: a frame is Janela's own word for the dashboard, so the gem's vocabulary never dictates what you call one (ADR 014).
|
|
124
181
|
|
|
125
182
|
```erb
|
|
126
|
-
<%=
|
|
127
|
-
<button type="button" data-action="janela--
|
|
183
|
+
<%= janela_frame do %>
|
|
184
|
+
<button type="button" data-action="janela--frame#clear">Clear filters</button>
|
|
128
185
|
|
|
129
186
|
<%= janela_pane Order, :revenue %>
|
|
130
187
|
<%= janela_pane Order, :revenue, by: :status, as: :bar %>
|
|
@@ -135,7 +192,91 @@ Compose panes on any page. Each pane is a Turbo Frame; clicking a value in one r
|
|
|
135
192
|
|
|
136
193
|
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
194
|
|
|
138
|
-
|
|
195
|
+
### Frames
|
|
196
|
+
|
|
197
|
+
A dashboard does not have to be written in ERB. A frame is a record, so the person who decides which panes a dashboard has and how wide each one is does not need a deploy to change it (ADR 012):
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
bin/rails janela:install:migrations && bin/rails db:migrate
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
```ruby
|
|
204
|
+
frame = Janela::Frame.create!(name: "Orders", columns: 3, gap: 4)
|
|
205
|
+
frame.panes.create!(model: "orders", measure: "revenue")
|
|
206
|
+
frame.panes.create!(model: "orders", measure: "revenue", dimension: "placed_on",
|
|
207
|
+
renderer: "line", granularity: "month", span: 3)
|
|
208
|
+
frame.panes.create!(model: "orders", measure: "revenue", dimension: "status",
|
|
209
|
+
renderer: "bar", span: 2, title: "Money by status")
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
```erb
|
|
213
|
+
<%= janela_frame @frame %>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Same helper, two ways to supply the panes. A row names a model by its route key, and only a measure, dimension, renderer and granularity the model's `janela` block declares: a row that names anything else is rejected on save, so an analyst arranges what is shown and cannot invent a query or reach a model nobody exposed. `position` orders the panes and is set for you when you leave it out. `title` is optional and replaces the title Janela would write itself.
|
|
217
|
+
|
|
218
|
+
The layout is CSS Grid's own vocabulary as small integers: `columns` 1 to 12 and `gap` 0 to 8 on the frame, `span` 1 to 12 on a pane. Each one picks a class the shipped stylesheet already defines, `janela-cols-3`, `janela-gap-4`, `janela-span-2`, so nothing an analyst types reaches CSS. Set `--janela-space` once, anywhere, to move the whole spacing scale; the grid collapses to a single column on a narrow screen. ADR 016 has the reasoning.
|
|
219
|
+
|
|
220
|
+
A frame renders each pane inline on the first response, so the page is a correct dashboard before any JavaScript runs and there is no request per pane on load. Cross-filtering then works exactly as it does for hand written panes. Every pane of a frame goes through your Pundit scope if you have one, the same as every other Janela query.
|
|
221
|
+
|
|
222
|
+
A frame may belong to an owner, `belongs_to :owner, polymorphic: true, optional: true`. Janela sets nothing there and reads nothing from it: it exists so a multi tenant host's Pundit `Scope` has a column to filter on. Set it to whatever your tenant is, and leave it null if you have one tenant.
|
|
223
|
+
|
|
224
|
+
### Janela's own pages
|
|
225
|
+
|
|
226
|
+
The engine serves an index and a page per frame at the mount root, so you can install the gem and navigate the same day:
|
|
227
|
+
|
|
228
|
+
```
|
|
229
|
+
/dashboards every frame your scope returns
|
|
230
|
+
/dashboards/3 one frame
|
|
231
|
+
/dashboards/orders/revenue/status an ad hoc pane, grammar unchanged
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
No model's route key is all digits, so a frame id and a pane URL cannot be confused. Both pages read through `policy_scope(Janela::Frame)`, so a frame your scope does not return is a 404 rather than a page, and so is a pane row under it.
|
|
235
|
+
|
|
236
|
+
These pages render in Janela's own minimal layout, which loads the gem's stylesheet and nothing else. It does not load Turbo or Stimulus, because those come from your bundler and the engine cannot name them. So Janela's own pages are correct, styled, **static** dashboards: every pane is rendered inline and the numbers are right, filters in the URL apply, and nothing cross-filters when you click. A chart pane needs Chart.js, so on these pages it draws nothing; put a frame on your own page, where your JavaScript is, for the interactive version.
|
|
237
|
+
|
|
238
|
+
The noun in the headings is `Janela::Frame.model_name.human`, so rename it in your own locale file rather than in a setting:
|
|
239
|
+
|
|
240
|
+
```yaml
|
|
241
|
+
en:
|
|
242
|
+
activerecord:
|
|
243
|
+
models:
|
|
244
|
+
janela/frame:
|
|
245
|
+
one: "Dashboard"
|
|
246
|
+
other: "Dashboards"
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
A host that wants a different index writes its own page over `Janela::Frame` and never routes to ours.
|
|
250
|
+
|
|
251
|
+
### Editing a dashboard
|
|
252
|
+
|
|
253
|
+
The same pages are the analyst's editing surface, as conventional Rails CRUD:
|
|
254
|
+
|
|
255
|
+
```
|
|
256
|
+
/dashboards/new name it and choose its grid
|
|
257
|
+
/dashboards/3/edit rename it, rearrange it, add and remove panes
|
|
258
|
+
/dashboards/3/panes/new
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
Everything there works with nothing but HTML, because those pages load no JavaScript. Adding a pane is therefore two steps: the first picks a model, the second offers exactly the measures and dimensions that model's `janela` block declares, so a choice that would be rejected is never offered. Granularity appears when the dimension can take one. A pane moves with Up and Down buttons rather than a position field, because arranging the window is the point, and positions stay contiguous. There is no drag and drop and no canvas; a visual editor is its own decision, not built.
|
|
262
|
+
|
|
263
|
+
**Tell Janela what a new frame belongs to.** If your `ApplicationController` defines `janela_frame_owner`, the engine assigns its return value as the owner of a frame it creates. Without it, a host whose `policy_scope` filters frames by owner would hide the analyst's new dashboard the instant it was saved:
|
|
264
|
+
|
|
265
|
+
```ruby
|
|
266
|
+
class ApplicationController < ActionController::Base
|
|
267
|
+
def janela_frame_owner
|
|
268
|
+
Current.account
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
A host with no tenancy defines nothing, gets a nil owner, and is correct: nothing is filtering on it. Every editing action reads through `policy_scope(Janela::Frame)` as well, so another tenant's frame is a 404 to change as much as to read. The [multi tenancy guide](docs/multi-tenancy.md) has the wiring for each of the common setups.
|
|
274
|
+
|
|
275
|
+
### Filters and clicks
|
|
276
|
+
|
|
277
|
+
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_in][]=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. Every selected value is marked `aria-pressed="true"` on tables and drawn solid against faded siblings on charts, so it can be styled and read.
|
|
278
|
+
|
|
279
|
+
**Selecting more than one.** Ctrl or Cmd click adds a value to the selection and takes it out again, leaving the rest alone, which is how every list in every operating system already behaves. A plain click selects one value and replaces whatever was selected, or clears the dimension if that value was the only one. It works the same on a chart. All of it works from the keyboard too: a value is a real `<button>`, so Enter is a click and Ctrl or Cmd with Enter adds. `Escape` clears the frame's filters, and those are the only two keys Janela binds, both only while focus is inside the frame, because a single letter belongs to your application and to any text field on the page (ADR 024). A pane with no matching rows renders a `.janela-empty` paragraph. A group whose dimension is null is labelled `(none)` and filters with Ransack's null predicate rather than an empty string. Only models that declare a `janela` block can be requested over HTTP.
|
|
139
280
|
|
|
140
281
|
### Pane URLs
|
|
141
282
|
|
|
@@ -151,15 +292,11 @@ Every pane has its own URL under the mount, and a Turbo Frame in a dashboard loa
|
|
|
151
292
|
orders revenue by placed_on, per week, as a line
|
|
152
293
|
```
|
|
153
294
|
|
|
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
|
|
295
|
+
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 with its filters applied, so a filtered pane is a link you can send someone. ADR 005 has the grammar, ADR 011 the layout it renders in.
|
|
155
296
|
|
|
156
297
|
### Snapshots
|
|
157
298
|
|
|
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
|
-
```
|
|
299
|
+
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. It needs the same migrations frames do.
|
|
163
300
|
|
|
164
301
|
```ruby
|
|
165
302
|
Janela::Snapshot.take(name: "September 2026", filters: { status_eq: "paid" }) do |take|
|
|
@@ -175,7 +312,32 @@ Render a stored pane the same way you render a live one:
|
|
|
175
312
|
<%= janela_snapshot_pane @snapshot, Order, :revenue, by: :status, as: :bar %>
|
|
176
313
|
```
|
|
177
314
|
|
|
178
|
-
|
|
315
|
+
Inside a dashboard a pane is a Turbo Frame and carries no layout at all. Opened directly it renders in Janela's own minimal layout, which deliberately loads no assets, because the gem cannot know your asset names or whether you bundle. A direct pane link therefore shows its numbers unstyled, and a chart pane shows nothing, since the chart needs Stimulus.
|
|
316
|
+
|
|
317
|
+
To make direct pane links styled and chart-capable, give Janela a small layout of your own that loads your assets and nothing else:
|
|
318
|
+
|
|
319
|
+
```erb
|
|
320
|
+
<%# app/views/layouts/janela.html.erb %>
|
|
321
|
+
<!DOCTYPE html>
|
|
322
|
+
<html>
|
|
323
|
+
<head>
|
|
324
|
+
<title><%= content_for(:title) || "Insights" %></title>
|
|
325
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
326
|
+
<%= csrf_meta_tags %>
|
|
327
|
+
<%= csp_meta_tag %>
|
|
328
|
+
<%= stylesheet_link_tag :app %>
|
|
329
|
+
<%= javascript_importmap_tags %>
|
|
330
|
+
</head>
|
|
331
|
+
<body><%= yield %></body>
|
|
332
|
+
</html>
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
```ruby
|
|
336
|
+
# config/initializers/janela.rb
|
|
337
|
+
Rails.application.config.to_prepare { Janela::ApplicationController.layout "janela" }
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
Do not point Janela at your **application** layout. Janela is an isolated engine, so a bare route helper anywhere in that layout, a nav link for instance, resolves against Janela's routes and raises `NameError`. Keep the layout above small and asset-only.
|
|
179
341
|
|
|
180
342
|
Stored panes are static by nature: no filter buttons, charts ignore clicks, and the URL says *as of*: `/dashboards/snapshots/42/orders/revenue/status`. Request filters are ignored because the snapshot's were fixed when it was taken.
|
|
181
343
|
|
|
@@ -191,30 +353,89 @@ Janela's controllers inherit from your `ApplicationController`, so they are exac
|
|
|
191
353
|
# config/initializers/janela.rb
|
|
192
354
|
Rails.application.config.to_prepare do
|
|
193
355
|
Janela::ApplicationController.prepend_before_action do
|
|
194
|
-
redirect_to
|
|
356
|
+
redirect_to new_session_path unless user_signed_in?
|
|
195
357
|
end
|
|
196
358
|
end
|
|
197
359
|
```
|
|
198
360
|
|
|
199
|
-
|
|
361
|
+
It is *prepended* so it runs before any filter on your `ApplicationController` that assumes a signed-in user (tenant lookups, audit logging).
|
|
362
|
+
|
|
363
|
+
Your own route helpers work in there. Janela is an isolated engine, so a bare `new_session_path` would normally resolve against Janela's routes and raise, and this bites any host code that generates a URL while inside the engine: an authentication concern, a `rescue_from` that redirects, an `after_action`. Janela forwards the route helpers it does not define itself to your application, so they behave as they do everywhere else (ADR 022). Two things to know. A name Janela also uses means Janela's in here, and `main_app.frames_path` says yours. And `url_for(@record)` resolves polymorphically with no name to forward, so that one still needs `main_app.`.
|
|
364
|
+
|
|
365
|
+
Scoping is automatic when you use Pundit: `Janela::ApplicationController` calls `policy_scope(model)` if your `ApplicationController` defines it, and falls back to `model.all` otherwise. Every model you put on a dashboard needs a policy with a `Scope`, and so do `Janela::Frame` and `Janela::Snapshot`: frames, pane rows and stored panes are all read through the scope, never around it. `test/dummy/app/controllers/application_controller.rb` is the smallest honest example of the wiring.
|
|
366
|
+
|
|
367
|
+
**Multi tenancy** has its own guide: [docs/multi-tenancy.md](docs/multi-tenancy.md). It covers what goes through your scope, worked wiring for Pundit, acts_as_tenant and CanCanCan, what owns a frame the analyst creates, and the one rough edge, which is that a snapshot has no owner column yet.
|
|
368
|
+
|
|
369
|
+
### The pages Janela serves
|
|
370
|
+
|
|
371
|
+
Mounting the engine gives you an index of frames and a page per frame with no
|
|
372
|
+
work at all, which is enough to navigate on the day you install it:
|
|
373
|
+
|
|
374
|
+
```
|
|
375
|
+
/insights every frame your policy scope returns
|
|
376
|
+
/insights/3 one frame
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
Both go through your `policy_scope`, so a frame another tenant owns is a 404. Each page carries a link back to your application's root, so they are not a dead end; rename it in your own locale file under `janela.actions.home`, or override the engine's layout if you want your whole navigation there.
|
|
380
|
+
|
|
381
|
+
These pages load Janela's own stylesheet and nothing of yours, because the gem
|
|
382
|
+
cannot know your asset names or bundler. Two consequences worth knowing. They
|
|
383
|
+
do not cross-filter, since that needs Stimulus. And a pane whose row asks for a
|
|
384
|
+
chart renders as its **table** here, because there is no chart runtime on the
|
|
385
|
+
page and a table needs nothing: the same frame rendered in your own page with
|
|
386
|
+
`janela_frame(@frame)` draws the chart. A renderer is a viewing choice, not part
|
|
387
|
+
of the pane (ADR 018).
|
|
388
|
+
|
|
389
|
+
### Checking an installation
|
|
390
|
+
|
|
391
|
+
```bash
|
|
392
|
+
bin/rails janela:doctor
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
Reads your application and lists what still needs doing: identifiers left over
|
|
396
|
+
from an earlier version, Stimulus controllers you have not registered, tables
|
|
397
|
+
you have not migrated, a `through:` dimension whose associated model does not
|
|
398
|
+
allowlist the attribute, a policy that scopes frames by an owner you never
|
|
399
|
+
supply, and whether the engine is mounted and authenticated. It exits non-zero
|
|
400
|
+
when it finds an error, so it works in CI. It only reads and reports.
|
|
401
|
+
|
|
402
|
+
Every finding names the check that produced it:
|
|
403
|
+
|
|
404
|
+
```
|
|
405
|
+
WARNING (unauthenticated-endpoints): no authentication filter found on ApplicationController
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
One check cannot be certain: Janela reads your controller's filters to guess
|
|
409
|
+
whether the endpoints are authenticated, so if you authenticate another way it
|
|
410
|
+
is a false alarm every run. Silence one you have judged, by name:
|
|
411
|
+
|
|
412
|
+
```ruby
|
|
413
|
+
# config/initializers/janela.rb
|
|
414
|
+
Janela.silenced_checks = %w[unauthenticated-endpoints]
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Silenced checks are named in the output every run, because a silence nobody
|
|
418
|
+
remembers is how a real finding goes unread (ADR 021).
|
|
200
419
|
|
|
201
|
-
|
|
420
|
+
Run it after installing and after any upgrade. Steps for a specific version
|
|
421
|
+
upgrade are in [UPGRADING.md](UPGRADING.md).
|
|
202
422
|
|
|
203
423
|
## Design
|
|
204
424
|
|
|
205
425
|
Janela ships the load-bearing core of a BI tool and nothing else. The reasoning is recorded in [`docs/decisions/`](docs/decisions/INDEX.md), starting with ADR 001.
|
|
206
426
|
|
|
207
|
-
- **Measures and dimensions are a Ruby DSL on the model**, config-as-code like `routes.rb`.
|
|
427
|
+
- **Measures and dimensions are a Ruby DSL on the model**, config-as-code like `routes.rb`. Developers define what can be asked.
|
|
428
|
+
- **Composition is data.** A frame and its panes are records, so analysts arrange what is shown without a deploy (ADR 012). A visual, drag-and-drop editor is not built and is a decision of its own.
|
|
208
429
|
- **Querying rides on [Ransack](https://github.com/activerecord-hackery/ransack)'s association-path traversal.** Janela does not invent a query language.
|
|
209
430
|
- **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.
|
|
210
431
|
- **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.
|
|
211
432
|
- **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.
|
|
212
433
|
|
|
213
|
-
Deliberately out of scope:
|
|
434
|
+
Deliberately out of scope: natural-language query, a separate data warehouse, a row-level-security subsystem (use your app's Pundit/CanCanCan), refresh-scheduling UI (schedule the Snapshot job with whatever you already use), embedding SDK, mobile app, print/paginated reports. If you need one of those, the codebase is meant to be small enough to fork and add your own.
|
|
214
435
|
|
|
215
436
|
## Status
|
|
216
437
|
|
|
217
|
-
**v0.
|
|
438
|
+
**v0.4.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar and line charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames, the engine's own pages for reading and editing them and the optional vitral theme work and are covered by unit and real-browser tests. Not yet built: a visual editor, drill-down on time panes, other chart types. Open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
|
|
218
439
|
|
|
219
440
|
## Development
|
|
220
441
|
|