also looking at this
Expand BunPress guide and pre-E2E quality gates
#35
40 files
+1524
-373
| @@ -1,144 +1,50 @@ | ||
| 1 | 1 | # Report builder |
| 2 | 2 | |
| 3 | A report is a grid of blocks. Each block runs one query and draws one thing. | |
| 4 | ||
| 5 | The terms here are the terms the interface uses, because both come from the | |
| 6 | same validation schema in `app/Reports/schema.ts`. A test asserts this page | |
| 7 | lists exactly the values that schema accepts, so a new measure cannot appear in | |
| 8 | the 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 | ||
| 26 | Every block except `text` carries a query with these parts. | |
| 27 | ||
| 28 | ### events | |
| 29 | ||
| 30 | The event names the block reads. Empty means every event in the project. Names | |
| 31 | come from the models you described in `config/reportshq.php`. | |
| 32 | ||
| 33 | ### measure | |
| 34 | ||
| 35 | What 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` | |
| 47 | ignore one. | |
| 48 | ||
| 49 | ### field | |
| 50 | ||
| 51 | Which value the measure reads. One of `name`, `user_key`, `session_key`, | |
| 52 | `value`, `currency`, `occurred_at`, or `properties.<key>` for anything in your | |
| 53 | own property bag. | |
| 54 | ||
| 55 | This is an allowlist rather than a pattern. These names reach a query builder, | |
| 56 | and "anything that looks like an identifier" is how a column name becomes an | |
| 57 | injection point the day somebody builds the query with a template string. | |
| 58 | ||
| 59 | ### dimension | |
| 60 | ||
| 61 | Group the result into series by a field, using the same set as above. Bounded | |
| 62 | by **limit** with an explicit other bucket: when two hundred tags become the top | |
| 63 | ten, the report says how many went into Other rather than letting a reader | |
| 64 | assume the tail does not exist. | |
| 65 | ||
| 66 | ### filters | |
| 67 | ||
| 68 | Narrow what the block counts. Each filter is a field, an operator and usually a | |
| 69 | value: | |
| 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 | ||
| 86 | How time is bucketed: `hour`, `day`, `week` or `month`. | |
| 87 | ||
| 88 | ### compare | |
| 89 | ||
| 90 | Compare against the previous period of equal length. When there was no previous | |
| 91 | period, the block says so rather than inventing a percentage. This is how | |
| 92 | dashboards end up claiming a four thousand percent rise, and it is always | |
| 93 | because the previous period was zero. | |
| 94 | ||
| 95 | ### limit | |
| 96 | ||
| 97 | Top N series when a dimension is set. The rest become Other, and the count is | |
| 98 | stated. | |
| 99 | ||
| 100 | ### steps | |
| 101 | ||
| 102 | Ordered event names, for `funnel` blocks only. A funnel measures people, not | |
| 103 | events, so when data is sampled the sampling keeps whole subjects: somebody is | |
| 104 | either wholly in the sample or wholly out. Sampling events independently makes | |
| 105 | every conversion rate noise, because the steps stop belonging to the same | |
| 106 | people. | |
| 107 | ||
| 108 | ## The grid | |
| 109 | ||
| 110 | Twelve columns. Drag a block to move it, its corner to resize, and everything | |
| 111 | has a keyboard path. Blocks push each other out of the way while you hold one, | |
| 112 | and the server settles the layout before saving, so two blocks can never share | |
| 113 | a cell. That last part is on the server deliberately: a layout agreed by one | |
| 114 | browser is a layout that disagrees with itself when two people edit at once. | |
| 115 | ||
| 116 | ## Draft and published | |
| 117 | ||
| 118 | Edits 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 | |
| 120 | half finished experiment is never what somebody else walks into. | |
| 3 | A 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 | ||
| 7 | Both 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 | ||
| 21 | The 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 | ||
| 25 | Each 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 | ``` | |
| 121 | 37 | |
| 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. | |
| 123 | 39 | |
| 124 | Each block carries one sentence derived from the query it actually runs, not | |
| 125 | from a label somebody typed. Two charts both called Revenue can count different | |
| 126 | things, and a reader deserves to know which one is in front of them before they | |
| 127 | quote 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. | |
| 128 | 41 | |
| 129 | ## Report filters | |
| 42 | The 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. | |
| 130 | 43 | |
| 131 | A whole report can be narrowed from its URL: | |
| 44 | ## Editing and publishing | |
| 132 | 45 | |
| 133 | ``` | |
| 134 | /reports/my-report?f=properties.plan:is:pro | |
| 135 | ``` | |
| 46 | A 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. | |
| 136 | 47 | |
| 137 | The form is `f=field:operator:value`, repeated, up to five. It lives in the | |
| 138 | query string and nowhere else, so a narrowed view is linkable: paste the URL | |
| 139 | into a conversation and the reader sees what you saw. | |
| 48 | In 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. | |
| 140 | 49 | |
| 141 | Operators here are limited to `is`, `is_not` and `contains`. A malformed filter | |
| 142 | is dropped rather than erroring, so a link with one bad filter still shows the | |
| 143 | report. Funnels are left alone, because quietly narrowing their steps would | |
| 144 | change what the conversion rate measures without saying so. | |
| 50 | One 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. | |