ReviewOS

also looking at this

stacks/reportshq

Expand BunPress guide and pre-E2E quality gates

#35
Merged glennmichael123 wants to merge docs/bunpress-product-guide into main
40 files +1524 -373
docs/builder.mdmodified+41-135
Changes to docs/builder.md
@@ -1,144 +1,50 @@
11# Report builder
22
3A report is a grid of blocks. Each block runs one query and draws one thing.
4
5The terms here are the terms the interface uses, because both come from the
6same validation schema in `app/Reports/schema.ts`. A test asserts this page
7lists exactly the values that schema accepts, so a new measure cannot appear in
8the product without appearing here.
9
10## Blocks
11
12| Kind | Draws |
13|---|---|
14| `line` | A measure over time |
15| `area` | The same, filled, for one dominant series |
16| `bar` | A measure across a dimension, or over coarse time |
17| `donut` | Composition of a total across a dimension |
18| `big_number` | One number, and whether it moved |
19| `table` | The underlying rows, when the shape is not a chart |
20| `funnel` | Conversion across ordered steps |
21| `heatmap` | A measure across two dimensions |
22| `text` | Prose. The only kind that runs no query |
23
24## The query
25
26Every block except `text` carries a query with these parts.
27
28### events
29
30The event names the block reads. Empty means every event in the project. Names
31come from the models you described in `config/reportshq.php`.
32
33### measure
34
35What to compute:
36
37| Measure | Means |
38|---|---|
39| `count` | Rows. The default, and what a count of events means |
40| `count_unique` | Distinct `user_key`. Unique visitors, active users, buyers |
41| `sum` | Sum of the named field. Revenue, duration, quantity |
42| `avg` | Mean of the named field. Order value, session length |
43| `min` | Smallest value of the named field |
44| `max` | Largest value of the named field |
45
46`sum`, `avg`, `min` and `max` require a **field**. `count` and `count_unique`
47ignore one.
48
49### field
50
51Which value the measure reads. One of `name`, `user_key`, `session_key`,
52`value`, `currency`, `occurred_at`, or `properties.<key>` for anything in your
53own property bag.
54
55This is an allowlist rather than a pattern. These names reach a query builder,
56and "anything that looks like an identifier" is how a column name becomes an
57injection point the day somebody builds the query with a template string.
58
59### dimension
60
61Group the result into series by a field, using the same set as above. Bounded
62by **limit** with an explicit other bucket: when two hundred tags become the top
63ten, the report says how many went into Other rather than letting a reader
64assume the tail does not exist.
65
66### filters
67
68Narrow what the block counts. Each filter is a field, an operator and usually a
69value:
70
71| Operator | Means |
72|---|---|
73| `is` | Equal to |
74| `is_not` | Not equal to |
75| `contains` | Substring |
76| `starts_with` | Prefix |
77| `gt` | Greater than |
78| `lt` | Less than |
79| `exists` | The field is present |
80| `not_exists` | The field is absent |
81
82`exists` and `not_exists` carry no value, because the test is presence.
83
84### grain
85
86How time is bucketed: `hour`, `day`, `week` or `month`.
87
88### compare
89
90Compare against the previous period of equal length. When there was no previous
91period, the block says so rather than inventing a percentage. This is how
92dashboards end up claiming a four thousand percent rise, and it is always
93because the previous period was zero.
94
95### limit
96
97Top N series when a dimension is set. The rest become Other, and the count is
98stated.
99
100### steps
101
102Ordered event names, for `funnel` blocks only. A funnel measures people, not
103events, so when data is sampled the sampling keeps whole subjects: somebody is
104either wholly in the sample or wholly out. Sampling events independently makes
105every conversion rate noise, because the steps stop belonging to the same
106people.
107
108## The grid
109
110Twelve columns. Drag a block to move it, its corner to resize, and everything
111has a keyboard path. Blocks push each other out of the way while you hold one,
112and the server settles the layout before saving, so two blocks can never share
113a cell. That last part is on the server deliberately: a layout agreed by one
114browser is a layout that disagrees with itself when two people edit at once.
115
116## Draft and published
117
118Edits go to a draft. The published version is what a teammate opens and what a
119[share link](/docs/sharing) serves, and it does not change until you publish. A
120half finished experiment is never what somebody else walks into.
3A report is a grid of blocks. The current source has two storage models: `@reportshq/stacks` asks the host for a `ReportStore`, while `reportshq/laravel` ships Eloquent report tables and a browser editor. The editor in this site's old hosted app was removed. Its read-only Accounts report is defined in `config/reportshq.ts`.
4
5## Block kinds
6
7Both current packages accept the same nine kind names in `packages/stacks/src/http/handlers.ts` and `packages/laravel/src/Reports/Block.php`:
8
9| Kind | Use |
10| --- | --- |
11| `big_number` | A headline value |
12| `line` | A time series |
13| `area` | A filled time series |
14| `bar` | Values over time or categories |
15| `donut` | Category composition |
16| `table` | Values in rows |
17| `funnel` | A funnel chart frame |
18| `heatmap` | A heatmap chart frame |
19| `note` | Text without a query |
20
21The current query engines do not have a separate ordered-step funnel calculation or two-dimensional heatmap aggregation. Do not infer those analytical semantics from the renderer kind alone.
22
23## Query vocabulary
24
25Each query-bearing block starts with `model` and `measure`, both looked up in the semantic registry. Measures are described with `count`, `sum`, `avg`, `min`, or `max`. Only `count` needs no column. The TypeScript block shape is `BlockQuery` in `packages/stacks/src/types.ts`:
26
27```ts
28{
29 model: 'order',
30 measure: 'revenue',
31 time: { key: 'placed' },
32 grain: 'day',
33 from: '2026-08-01',
34 to: '2026-09-01',
35}
36```
12137
122## Every block explains itself
38`dimension` groups by an allowlisted column. `time` selects an allowlisted date column on the measured model; `grain` can be `hour`, `day`, `week`, or `month`. `from` is inclusive and `to` is exclusive. A date range without `time` uses the model's first described time column. `limit` bounds a dimensioned result to at most 500 rows in the TypeScript compiler. The current TypeScript compiler accepts `=`, `!=`, `>`, `>=`, `<`, `<=`, and `like` for filters. Laravel's `Filter` uses the named operators `is`, `is_not`, `contains`, `starts_with`, `gt`, `lt`, `exists`, and `not_exists`. Do not copy a filter payload between the two runtimes without translating the operator vocabulary.
12339
124Each block carries one sentence derived from the query it actually runs, not
125from a label somebody typed. Two charts both called Revenue can count different
126things, and a reader deserves to know which one is in front of them before they
127quote a number in a meeting.
40`compare` is present in the TypeScript type, but the current TypeScript compiler returns `comparison: null`. It does not yet compute a prior-period change. The retired event fields `events`, `user_key`, `session_key`, `properties.<key>`, and `steps` are not this reporting query schema.
12841
129## Report filters
42The registry supplies every SQL identifier and the caller supplies only keys and parameterized values. A column not described in the registry is unreachable by a block request.
13043
131A whole report can be narrowed from its URL:
44## Editing and publishing
13245
133```
134/reports/my-report?f=properties.plan:is:pro
135```
46A Stacks host can keep reports in code and make the viewer read-only. The browser builder is writable only when the store implements `addBlock`, `saveBlock`, `removeBlock`, and `publish`; a partially implemented editor is not advertised as writable. The host mounts the package's route descriptions and chooses auth and persistence. This site intentionally does not mount the builder.
13647
137The form is `f=field:operator:value`, repeated, up to five. It lives in the
138query string and nowhere else, so a narrowed view is linkable: paste the URL
139into a conversation and the reader sees what you saw.
48In Laravel, `Builder` adds blocks, validates their kind, packs a 12-column layout on the server, and publishes a revision. The package's standalone editor is under `/reports/{slug}/edit` only after `routes.enabled` is set and the host chooses suitable middleware. The Filament plugin is another optional surface.
14049
141Operators here are limited to `is`, `is_not` and `contains`. A malformed filter
142is dropped rather than erroring, so a link with one bad filter still shows the
143report. Funnels are left alone, because quietly narrowing their steps would
144change what the conversion rate measures without saying so.
50One impossible block returns its reason in `error` while the report can still render other blocks. A fan-out relation that would multiply a sum is refused instead of shown as a plausible wrong number. See [the query API](/docs/api) for the result shape.